tt-help-cli-ycl 1.4.183 → 1.4.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/test-candidate-retry.mjs +496 -0
- package/scripts/test-crash-circuit-breaker.mjs +250 -0
- package/scripts/test-error-auto-reset.mjs +103 -0
- package/scripts/test-error-report.mjs +215 -0
- package/scripts/test-login-pool-acquire-order.mjs +92 -26
- package/scripts/test-login-pool-faceban-e2e.mjs +131 -0
- package/scripts/test-login-pool-faceban.mjs +235 -0
- package/scripts/test-login-pool-ui.mjs +2 -1
- package/scripts/test-login-pool.mjs +97 -54
- package/scripts/test-process-manager-label.mjs +145 -0
- package/src/cli/explore.js +171 -25
- package/src/cli/search.js +171 -18
- package/src/lib/browser/health-checker.js +19 -3
- package/src/lib/browser/page.js +23 -0
- package/src/lib/error-report.js +119 -0
- package/src/lib/login-pool-client.js +123 -0
- package/src/watch/data-store.js +25 -0
- package/src/watch/login-pool.js +118 -81
- package/src/watch/public/app.js +7 -2
- package/src/watch/public/index.html +2 -1
- package/src/watch/server.js +53 -8
- package/src/watchdog/process-manager.js +71 -6
- package/src/watchdog/reporter.js +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 候选注入失败「原号重试」改造(客户端 explore.js/search.js)单测:
|
|
3
|
+
*
|
|
4
|
+
* 背景:f11 节点 48h 实测 8208 条注入验证失败,~17% 为瞬态环境失败
|
|
5
|
+
* (page.goto 超时 / AbortError / net::ERR_* / Execution context destroyed / 无账号数据),
|
|
6
|
+
* 0 条会话过期。改造前对非崩溃失败一律立即弃号换下一个候选,网络劣化窗口
|
|
7
|
+
* 会连续烧掉 5 个候选且全死于同一环境。
|
|
8
|
+
*
|
|
9
|
+
* 覆盖:
|
|
10
|
+
* A. isDeterministicAccountFailure(确定性号死:会话过期/身份不符)
|
|
11
|
+
* B. isRetryableInjectFailure(瞬态/环境失败白名单谓词)
|
|
12
|
+
* C. decideCandidateInjectAction(三路分流决策:崩溃优先熔断 > 瞬态重试 > 其余弃号)
|
|
13
|
+
* D. retryRestoreOnNavFailure 驱动 + onRetry 钩子(注入式,纯逻辑)
|
|
14
|
+
* D1. 瞬态失败重试 2 次后成功(共 3 次尝试)
|
|
15
|
+
* D2. 3 次全败(attempts=3 后停止)
|
|
16
|
+
* D3/D4. 会话过期 / 身份不符零重试
|
|
17
|
+
* D5. 崩溃签名不进重试(驱动层兜底;熔断分流由 C 节保证)
|
|
18
|
+
* D6. shouldAbort 中断
|
|
19
|
+
* E. 候选循环小模型(与 switchPoolAccount 同构):验证租约保持在手 / 弃号时机 /
|
|
20
|
+
* 崩溃中止循环 / acquire 次数不虚增
|
|
21
|
+
*
|
|
22
|
+
* 运行:node scripts/test-candidate-retry.mjs
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
CANDIDATE_INJECT_MAX_RETRIES,
|
|
26
|
+
CANDIDATE_INJECT_DELAY_MS,
|
|
27
|
+
isDeterministicAccountFailure,
|
|
28
|
+
isRetryableInjectFailure,
|
|
29
|
+
decideCandidateInjectAction,
|
|
30
|
+
retryRestoreOnNavFailure,
|
|
31
|
+
} from "../src/lib/login-pool-client.js";
|
|
32
|
+
import { isBrowserClosedError } from "../src/lib/browser/page.js";
|
|
33
|
+
|
|
34
|
+
let pass = 0;
|
|
35
|
+
let fail = 0;
|
|
36
|
+
|
|
37
|
+
function assert(cond, label) {
|
|
38
|
+
if (cond) {
|
|
39
|
+
pass++;
|
|
40
|
+
console.error(` ✓ ${label}`);
|
|
41
|
+
} else {
|
|
42
|
+
fail++;
|
|
43
|
+
console.error(` ✗ ${label}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const TRANSIENT_TIMEOUT = "page.goto: Timeout 30000ms exceeded.";
|
|
48
|
+
const TRANSIENT_NET = "page.goto: net::ERR_TIMED_OUT at https://www.tiktok.com";
|
|
49
|
+
|
|
50
|
+
// ── A. isDeterministicAccountFailure ──
|
|
51
|
+
{
|
|
52
|
+
console.error("\n[A] isDeterministicAccountFailure 确定性号死");
|
|
53
|
+
assert(isDeterministicAccountFailure("会话过期") === true, "「会话过期」命中");
|
|
54
|
+
assert(
|
|
55
|
+
isDeterministicAccountFailure("身份不符(期望 @a,实际 @b)") === true,
|
|
56
|
+
"「身份不符(…)」命中(含前后缀文案)",
|
|
57
|
+
);
|
|
58
|
+
assert(
|
|
59
|
+
isDeterministicAccountFailure(TRANSIENT_TIMEOUT) === false,
|
|
60
|
+
"超时不算号死",
|
|
61
|
+
);
|
|
62
|
+
assert(isDeterministicAccountFailure("") === false, "空串不命中");
|
|
63
|
+
assert(isDeterministicAccountFailure(null) === false, "null 不命中");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── B. isRetryableInjectFailure 瞬态/环境失败白名单 ──
|
|
67
|
+
{
|
|
68
|
+
console.error("\n[B] isRetryableInjectFailure 瞬态白名单");
|
|
69
|
+
assert(isRetryableInjectFailure(TRANSIENT_TIMEOUT) === true, "超时 → 可重试");
|
|
70
|
+
assert(isRetryableInjectFailure(TRANSIENT_NET) === true, "net::ERR_TIMED_OUT → 可重试");
|
|
71
|
+
assert(
|
|
72
|
+
isRetryableInjectFailure("page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE") === true,
|
|
73
|
+
"net::ERR_HTTP_RESPONSE_CODE_FAILURE → 可重试",
|
|
74
|
+
);
|
|
75
|
+
assert(isRetryableInjectFailure("AbortError") === true, "AbortError → 可重试");
|
|
76
|
+
assert(
|
|
77
|
+
isRetryableInjectFailure(
|
|
78
|
+
"Execution context was destroyed, most likely because of a navigation",
|
|
79
|
+
) === true,
|
|
80
|
+
"Execution context destroyed → 可重试",
|
|
81
|
+
);
|
|
82
|
+
assert(isRetryableInjectFailure("无账号数据") === true, "无账号数据 → 可重试");
|
|
83
|
+
assert(
|
|
84
|
+
isRetryableInjectFailure("导航失败: page.goto: net::ERR_ABORTED") === true,
|
|
85
|
+
"导航失败前缀 → 可重试",
|
|
86
|
+
);
|
|
87
|
+
// 不可重试侧
|
|
88
|
+
assert(isRetryableInjectFailure("会话过期") === false, "会话过期 → 不可重试(号死)");
|
|
89
|
+
assert(
|
|
90
|
+
isRetryableInjectFailure("身份不符(期望 @x,实际 @y)") === false,
|
|
91
|
+
"身份不符 → 不可重试(号死)",
|
|
92
|
+
);
|
|
93
|
+
assert(isRetryableInjectFailure("Page crashed") === false, "Page crashed → 不可重试(走熔断)");
|
|
94
|
+
assert(isRetryableInjectFailure("Target crashed") === false, "Target crashed → 不可重试(走熔断)");
|
|
95
|
+
assert(
|
|
96
|
+
isRetryableInjectFailure("账号 cookies 为空") === false,
|
|
97
|
+
"未知失败 → 不可重试(保守兜底,维持现状弃号)",
|
|
98
|
+
);
|
|
99
|
+
assert(isRetryableInjectFailure("") === false, "空串 → false");
|
|
100
|
+
assert(isRetryableInjectFailure(null) === false, "null → false");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── C. decideCandidateInjectAction 三路分流(真实 isBrowserClosedError 注入)──
|
|
104
|
+
{
|
|
105
|
+
console.error("\n[C] decideCandidateInjectAction 决策");
|
|
106
|
+
const d = (reason) => decideCandidateInjectAction(reason, isBrowserClosedError);
|
|
107
|
+
assert(d("Page crashed") === "circuit-break", "Page crashed → circuit-break");
|
|
108
|
+
assert(d("Target crashed") === "circuit-break", "Target crashed → circuit-break");
|
|
109
|
+
assert(
|
|
110
|
+
d("Target page, context or browser has been closed") === "circuit-break",
|
|
111
|
+
"浏览器关闭签名 → circuit-break",
|
|
112
|
+
);
|
|
113
|
+
assert(
|
|
114
|
+
d("page.goto: net::ERR_CONNECTION_RESET") === "circuit-break",
|
|
115
|
+
"net::ERR_CONNECTION_* 命中崩溃签名(BROWSER_CLOSED_PATTERNS)→ 优先熔断(现状语义保持)",
|
|
116
|
+
);
|
|
117
|
+
assert(d(TRANSIENT_TIMEOUT) === "retry", "超时 → retry");
|
|
118
|
+
assert(d(TRANSIENT_NET) === "retry", "net::ERR_TIMED_OUT → retry");
|
|
119
|
+
assert(d("AbortError") === "retry", "AbortError → retry");
|
|
120
|
+
assert(d("无账号数据") === "retry", "无账号数据 → retry");
|
|
121
|
+
assert(d("会话过期") === "discard", "会话过期 → discard(立即弃号换候选)");
|
|
122
|
+
assert(
|
|
123
|
+
d("身份不符(期望 @a,实际 @b)") === "discard",
|
|
124
|
+
"身份不符 → discard",
|
|
125
|
+
);
|
|
126
|
+
assert(d("账号 cookies 为空") === "discard", "未知失败 → discard(现状兜底)");
|
|
127
|
+
assert(
|
|
128
|
+
decideCandidateInjectAction("Page crashed") === "discard",
|
|
129
|
+
"防御:未注入崩溃谓词时不误判熔断(调用方恒传谓词,此处仅验证缺省安全)",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── D. retryRestoreOnNavFailure 驱动 + onRetry 钩子 ──
|
|
134
|
+
// 注:驱动语义「首次尝试已发生」——firstFailure 即首败结果,
|
|
135
|
+
// attemptResults 只含重试轮序列(第2/3次尝试)
|
|
136
|
+
async function drive({ attemptResults, firstFailure, shouldAbort, onInject }) {
|
|
137
|
+
const calls = { sleepMs: 0, onRetry: [], attempts: 0, inject: 0 };
|
|
138
|
+
const seq = [...attemptResults];
|
|
139
|
+
const r = await retryRestoreOnNavFailure(
|
|
140
|
+
() => {
|
|
141
|
+
calls.inject++;
|
|
142
|
+
if (typeof onInject === "function") onInject(calls.inject);
|
|
143
|
+
const item = seq.shift();
|
|
144
|
+
return Promise.resolve(item ?? { ok: true });
|
|
145
|
+
},
|
|
146
|
+
firstFailure,
|
|
147
|
+
{
|
|
148
|
+
isRetryable: isRetryableInjectFailure,
|
|
149
|
+
maxRetries: CANDIDATE_INJECT_MAX_RETRIES,
|
|
150
|
+
delayMs: CANDIDATE_INJECT_DELAY_MS,
|
|
151
|
+
sleep: async (ms) => {
|
|
152
|
+
calls.sleepMs += ms;
|
|
153
|
+
},
|
|
154
|
+
shouldAbort: shouldAbort || (() => false),
|
|
155
|
+
onRetry: (n, delayMs, why) => calls.onRetry.push({ n, delayMs, why }),
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
return { r, calls };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
console.error("\n[D] retryRestoreOnNavFailure 驱动(注入式)");
|
|
163
|
+
// D1. 瞬态失败重试 2 次后成功(首败 net::ERR_ABORTED + 重试轮:失败→成功)
|
|
164
|
+
{
|
|
165
|
+
const { r, calls } = await drive({
|
|
166
|
+
attemptResults: [
|
|
167
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
168
|
+
{ ok: true, username: "t1" },
|
|
169
|
+
],
|
|
170
|
+
firstFailure: "page.goto: net::ERR_ABORTED",
|
|
171
|
+
});
|
|
172
|
+
assert(r.ok === true, "D1 第 3 次尝试成功 → ok");
|
|
173
|
+
assert(r.attempts === 3, "D1 attempts=3(共 3 次尝试)");
|
|
174
|
+
assert(r.username === "t1", "D1 成功结果字段透传");
|
|
175
|
+
assert(calls.inject === 2, "D1 重试轮注入 2 次(首败已发生,不重复计入)");
|
|
176
|
+
assert(
|
|
177
|
+
calls.sleepMs === 2 * CANDIDATE_INJECT_DELAY_MS,
|
|
178
|
+
"D1 重试间隔 2 次 × 15s",
|
|
179
|
+
);
|
|
180
|
+
assert(
|
|
181
|
+
calls.onRetry.length === 2 &&
|
|
182
|
+
calls.onRetry[0].n === 1 &&
|
|
183
|
+
calls.onRetry[1].n === 2,
|
|
184
|
+
"D1 onRetry 回调 2 次(第1/2次失败后各一次)",
|
|
185
|
+
);
|
|
186
|
+
assert(
|
|
187
|
+
calls.onRetry[0].delayMs === CANDIDATE_INJECT_DELAY_MS,
|
|
188
|
+
"D1 onRetry 携带间隔时长(供日志「15s 后原号重试」)",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
// D2. 3 次全败
|
|
192
|
+
{
|
|
193
|
+
const { r, calls } = await drive({
|
|
194
|
+
attemptResults: [
|
|
195
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
196
|
+
{ ok: false, reason: "无账号数据" },
|
|
197
|
+
],
|
|
198
|
+
firstFailure: TRANSIENT_TIMEOUT,
|
|
199
|
+
});
|
|
200
|
+
assert(r.ok === false, "D2 3 次全败 → ok=false");
|
|
201
|
+
assert(r.attempts === 3, "D2 attempts=3 后停止");
|
|
202
|
+
assert(calls.inject === 2, "D2 重试轮恰好 2 次(不无限重试)");
|
|
203
|
+
assert(calls.onRetry.length === 2, "D2 onRetry 仅 2 次(第 3 次失败后不再回调)");
|
|
204
|
+
}
|
|
205
|
+
// D3/D4. 确定性号死零重试
|
|
206
|
+
for (const [label, reason] of [
|
|
207
|
+
["D3 会话过期", "会话过期"],
|
|
208
|
+
["D4 身份不符", "身份不符(期望 @pool,实际 @other)"],
|
|
209
|
+
]) {
|
|
210
|
+
const { r, calls } = await drive({
|
|
211
|
+
attemptResults: [],
|
|
212
|
+
firstFailure: reason,
|
|
213
|
+
});
|
|
214
|
+
assert(r.ok === false && r.attempts === 1, `${label} 零重试(attempts=1)`);
|
|
215
|
+
assert(calls.inject === 0 && calls.sleepMs === 0, `${label} 不再注入不等待`);
|
|
216
|
+
assert(calls.onRetry.length === 0, `${label} 无 onRetry 回调`);
|
|
217
|
+
}
|
|
218
|
+
// D5. 崩溃签名不进重试(驱动层兜底;分流到熔断由 C 节决策保证)
|
|
219
|
+
{
|
|
220
|
+
const { r, calls } = await drive({
|
|
221
|
+
attemptResults: [],
|
|
222
|
+
firstFailure: "Page crashed",
|
|
223
|
+
});
|
|
224
|
+
assert(r.ok === false && r.attempts === 1, "D5 崩溃签名零重试");
|
|
225
|
+
assert(calls.sleepMs === 0, "D5 不消耗重试等待");
|
|
226
|
+
}
|
|
227
|
+
// D6. shouldAbort 中断(中断点:sleep 之后、下一次尝试之前)
|
|
228
|
+
{
|
|
229
|
+
const { r, calls } = await drive({
|
|
230
|
+
attemptResults: [],
|
|
231
|
+
firstFailure: TRANSIENT_TIMEOUT,
|
|
232
|
+
shouldAbort: () => true,
|
|
233
|
+
});
|
|
234
|
+
assert(
|
|
235
|
+
r.aborted === true && r.ok === false,
|
|
236
|
+
"D6 shouldAbort 命中 → aborted 返回",
|
|
237
|
+
);
|
|
238
|
+
assert(r.attempts === 1, "D6 中断时 attempts 不虚增");
|
|
239
|
+
assert(calls.inject === 0, "D6 中断后不再尝试注入");
|
|
240
|
+
// 第二轮重试等待后才中断:attempts 停在 2,重试轮注入恰好 1 次
|
|
241
|
+
let abortAfterOne = false;
|
|
242
|
+
const { r: r2, calls: c2 } = await drive({
|
|
243
|
+
attemptResults: [{ ok: false, reason: TRANSIENT_TIMEOUT }],
|
|
244
|
+
firstFailure: TRANSIENT_TIMEOUT,
|
|
245
|
+
shouldAbort: () => abortAfterOne,
|
|
246
|
+
onInject: () => {
|
|
247
|
+
abortAfterOne = true;
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
assert(
|
|
251
|
+
r2.aborted === true && r2.attempts === 2,
|
|
252
|
+
"D6 第二轮等待后中断 → attempts=2",
|
|
253
|
+
);
|
|
254
|
+
assert(c2.inject === 1, "D6 中断前完成的重试轮注入恰好 1 次");
|
|
255
|
+
}
|
|
256
|
+
// D7. 重试中途失败升级为崩溃签名 → 驱动立即停止(不再消耗第 3 次尝试),
|
|
257
|
+
// 最终 reason 透传给调用方重判(熔断分流在 E8 小模型验证)
|
|
258
|
+
{
|
|
259
|
+
const { r, calls } = await drive({
|
|
260
|
+
attemptResults: [{ ok: false, reason: "Page crashed" }],
|
|
261
|
+
firstFailure: TRANSIENT_TIMEOUT,
|
|
262
|
+
});
|
|
263
|
+
assert(
|
|
264
|
+
r.ok === false && r.attempts === 2 && r.reason === "Page crashed",
|
|
265
|
+
"D7 中途崩溃 → attempts=2 停止,最终 reason=Page crashed",
|
|
266
|
+
);
|
|
267
|
+
assert(calls.inject === 1, "D7 不再进行第 3 次尝试");
|
|
268
|
+
assert(
|
|
269
|
+
decideCandidateInjectAction(r.reason, isBrowserClosedError) === "circuit-break",
|
|
270
|
+
"D7 最终 reason 重判命中熔断(调用方重判依据)",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── E. 候选循环小模型(与 switchPoolAccount 同构,验证租约保持在手/弃号时机)──
|
|
276
|
+
/**
|
|
277
|
+
* 复刻 explore.js/search.js 候选循环的注入失败分流骨架(单候选):
|
|
278
|
+
* acquire → inject → decideCandidateInjectAction →
|
|
279
|
+
* circuit-break: 熔断标记 + release 候选 + 中止(返回)
|
|
280
|
+
* retry: retryRestoreOnNavFailure(sleep/shouldAbort 注入)→ 成功 fall-through / 失败弃号
|
|
281
|
+
* discard: 直接弃号
|
|
282
|
+
* 返回观测记录 {action, acquire, release, injectCalls, sleepMs, retries, switched}
|
|
283
|
+
*/
|
|
284
|
+
async function runCandidateOnce({ injectSeq, shouldAbort }) {
|
|
285
|
+
const obs = {
|
|
286
|
+
acquire: 0,
|
|
287
|
+
release: 0,
|
|
288
|
+
injectCalls: 0,
|
|
289
|
+
sleepMs: 0,
|
|
290
|
+
retryLogs: [],
|
|
291
|
+
switched: false,
|
|
292
|
+
circuitBreak: false,
|
|
293
|
+
};
|
|
294
|
+
obs.acquire++; // 取候选号
|
|
295
|
+
let r = await Promise.resolve(injectSeq.shift() ?? { ok: true });
|
|
296
|
+
obs.injectCalls++;
|
|
297
|
+
if (!r.ok) {
|
|
298
|
+
let action = decideCandidateInjectAction(r.reason, isBrowserClosedError);
|
|
299
|
+
if (action === "circuit-break") {
|
|
300
|
+
obs.circuitBreak = true;
|
|
301
|
+
obs.release++; // 既有行为:已取候选照常 release,中止候选循环
|
|
302
|
+
return obs;
|
|
303
|
+
}
|
|
304
|
+
if (action === "retry") {
|
|
305
|
+
r = await retryRestoreOnNavFailure(
|
|
306
|
+
() => {
|
|
307
|
+
obs.injectCalls++;
|
|
308
|
+
return Promise.resolve(injectSeq.shift() ?? { ok: true });
|
|
309
|
+
},
|
|
310
|
+
r.reason,
|
|
311
|
+
{
|
|
312
|
+
isRetryable: isRetryableInjectFailure,
|
|
313
|
+
maxRetries: CANDIDATE_INJECT_MAX_RETRIES,
|
|
314
|
+
delayMs: CANDIDATE_INJECT_DELAY_MS,
|
|
315
|
+
sleep: async (ms) => {
|
|
316
|
+
obs.sleepMs += ms;
|
|
317
|
+
},
|
|
318
|
+
shouldAbort: shouldAbort || (() => false),
|
|
319
|
+
onRetry: (n, delayMs, why) =>
|
|
320
|
+
obs.retryLogs.push(
|
|
321
|
+
`[换号] @t 第${n}次注入失败(瞬态,${why}),${Math.round(delayMs / 1000)}s 后原号重试`,
|
|
322
|
+
),
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
if (r.aborted) {
|
|
326
|
+
obs.release++; // shuttingDown:释放候选退出
|
|
327
|
+
obs.aborted = true;
|
|
328
|
+
return obs;
|
|
329
|
+
}
|
|
330
|
+
if (!r.ok) {
|
|
331
|
+
// 补丁:重试中途失败签名可能升级(崩溃/号死),对最终原因重判——
|
|
332
|
+
// 与 switchPoolAccount 补丁后同构:命中熔断走熔断,否则弃号
|
|
333
|
+
action = decideCandidateInjectAction(r.reason, isBrowserClosedError);
|
|
334
|
+
if (action === "circuit-break") {
|
|
335
|
+
obs.circuitBreak = true;
|
|
336
|
+
obs.release++;
|
|
337
|
+
return obs;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!r.ok) {
|
|
342
|
+
obs.release++; // 3 次全败(或确定性号死/未知失败)才弃号
|
|
343
|
+
obs.finalReason = r.reason;
|
|
344
|
+
obs.finalAttempts = r.attempts;
|
|
345
|
+
return obs;
|
|
346
|
+
}
|
|
347
|
+
if (r.attempts > 1) obs.retrySuccessAt = r.attempts;
|
|
348
|
+
}
|
|
349
|
+
obs.switched = true; // 注入验证通过(含重试成功),不 release 新号(租约保持在手)
|
|
350
|
+
return obs;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
{
|
|
354
|
+
console.error("\n[E] 候选循环小模型(租约保持在手 / 弃号时机)");
|
|
355
|
+
// E1. 瞬态失败重试 2 次后成功:不弃号、不还池、不重新取号
|
|
356
|
+
{
|
|
357
|
+
const obs = await runCandidateOnce({
|
|
358
|
+
injectSeq: [
|
|
359
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
360
|
+
{ ok: false, reason: TRANSIENT_NET },
|
|
361
|
+
{ ok: true },
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
assert(obs.switched === true && obs.release === 0, "E1 重试成功 → 候选保持在手(release=0)");
|
|
365
|
+
assert(obs.acquire === 1, "E1 不重新取号(acquire=1)");
|
|
366
|
+
assert(obs.injectCalls === 3, "E1 共 3 次尝试");
|
|
367
|
+
assert(obs.retryLogs.length === 2, "E1 每次瞬态失败后打重试日志");
|
|
368
|
+
assert(
|
|
369
|
+
/第1次注入失败(瞬态.*).*15s 后原号重试/.test(obs.retryLogs[0]),
|
|
370
|
+
"E1 日志格式含「第N次注入失败(瞬态)…15s 后原号重试」",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
// E2. 3 次全败 → 弃号一次(release=1)
|
|
374
|
+
{
|
|
375
|
+
const obs = await runCandidateOnce({
|
|
376
|
+
injectSeq: [
|
|
377
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
378
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
379
|
+
{ ok: false, reason: "无账号数据" },
|
|
380
|
+
],
|
|
381
|
+
});
|
|
382
|
+
assert(
|
|
383
|
+
obs.switched === false && obs.release === 1,
|
|
384
|
+
"E2 3 次全败 → 恰好弃号一次",
|
|
385
|
+
);
|
|
386
|
+
assert(obs.finalAttempts === 3, "E2 弃号发生在共 3 次尝试之后");
|
|
387
|
+
assert(obs.injectCalls === 3, "E2 共 3 次尝试(不提前弃号)");
|
|
388
|
+
assert(obs.sleepMs === 2 * CANDIDATE_INJECT_DELAY_MS, "E2 重试等待 2 × 15s");
|
|
389
|
+
}
|
|
390
|
+
// E3. 会话过期 → 零重试直接弃号
|
|
391
|
+
{
|
|
392
|
+
const obs = await runCandidateOnce({
|
|
393
|
+
injectSeq: [{ ok: false, reason: "会话过期" }],
|
|
394
|
+
});
|
|
395
|
+
assert(
|
|
396
|
+
obs.switched === false && obs.release === 1 && obs.injectCalls === 1,
|
|
397
|
+
"E3 会话过期 → 零重试立即弃号",
|
|
398
|
+
);
|
|
399
|
+
assert(obs.retryLogs.length === 0 && obs.sleepMs === 0, "E3 无重试无等待");
|
|
400
|
+
}
|
|
401
|
+
// E4. 身份不符 → 零重试直接弃号
|
|
402
|
+
{
|
|
403
|
+
const obs = await runCandidateOnce({
|
|
404
|
+
injectSeq: [{ ok: false, reason: "身份不符(期望 @t,实际 @x)" }],
|
|
405
|
+
});
|
|
406
|
+
assert(
|
|
407
|
+
obs.switched === false && obs.release === 1 && obs.injectCalls === 1,
|
|
408
|
+
"E4 身份不符 → 零重试立即弃号",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
// E5. 崩溃 → 走熔断(不进重试),候选照常 release(既有行为)
|
|
412
|
+
{
|
|
413
|
+
const obs = await runCandidateOnce({
|
|
414
|
+
injectSeq: [{ ok: false, reason: "Page crashed" }],
|
|
415
|
+
});
|
|
416
|
+
assert(obs.circuitBreak === true, "E5 崩溃 → circuit-break 分流");
|
|
417
|
+
assert(obs.release === 1 && obs.injectCalls === 1, "E5 候选照常 release,不重试");
|
|
418
|
+
assert(obs.switched === false && obs.sleepMs === 0, "E5 不重试不等待");
|
|
419
|
+
}
|
|
420
|
+
// E6. net::ERR_CONNECTION_* → 崩溃签名优先熔断(现状语义,不进重试)
|
|
421
|
+
{
|
|
422
|
+
const obs = await runCandidateOnce({
|
|
423
|
+
injectSeq: [{ ok: false, reason: "page.goto: net::ERR_CONNECTION_RESET" }],
|
|
424
|
+
});
|
|
425
|
+
assert(
|
|
426
|
+
obs.circuitBreak === true && obs.retryLogs.length === 0,
|
|
427
|
+
"E6 net::ERR_CONNECTION_* → 熔断优先于重试(现状语义保持)",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
// E7. shuttingDown 中断 → 释放候选退出
|
|
431
|
+
{
|
|
432
|
+
const obs = await runCandidateOnce({
|
|
433
|
+
injectSeq: [{ ok: false, reason: TRANSIENT_TIMEOUT }],
|
|
434
|
+
shouldAbort: () => true,
|
|
435
|
+
});
|
|
436
|
+
assert(
|
|
437
|
+
obs.aborted === true && obs.release === 1 && obs.switched === false,
|
|
438
|
+
"E7 关闭信号中断 → 释放候选退出",
|
|
439
|
+
);
|
|
440
|
+
assert(obs.injectCalls === 1, "E7 中断后不再尝试注入");
|
|
441
|
+
}
|
|
442
|
+
// E8. 首败 timeout 进重试、第 2 次尝试失败=Page crashed → 重判走熔断(补验收缺口):
|
|
443
|
+
// 不弃号给下一候选(坏浏览器再吃一个号),crashStreak/退避由熔断分支接管
|
|
444
|
+
{
|
|
445
|
+
const obs = await runCandidateOnce({
|
|
446
|
+
injectSeq: [
|
|
447
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
448
|
+
{ ok: false, reason: "Page crashed" },
|
|
449
|
+
],
|
|
450
|
+
});
|
|
451
|
+
assert(
|
|
452
|
+
obs.circuitBreak === true,
|
|
453
|
+
"E8 重试中途升级崩溃 → 熔断分流(不落弃号分支)",
|
|
454
|
+
);
|
|
455
|
+
assert(
|
|
456
|
+
obs.switched === false && obs.release === 1,
|
|
457
|
+
"E8 候选照常 release,不切换不进入下一候选",
|
|
458
|
+
);
|
|
459
|
+
assert(
|
|
460
|
+
obs.finalAttempts === undefined,
|
|
461
|
+
"E8 不进弃号分支(无弃号 attempts 痕迹)",
|
|
462
|
+
);
|
|
463
|
+
assert(obs.injectCalls === 2, "E8 共 2 次尝试后停止(不再第 3 次)");
|
|
464
|
+
}
|
|
465
|
+
// E9. 首败 timeout 进重试、第 2 次尝试=会话过期 → 弃号换候选(不误入熔断)
|
|
466
|
+
{
|
|
467
|
+
const obs = await runCandidateOnce({
|
|
468
|
+
injectSeq: [
|
|
469
|
+
{ ok: false, reason: TRANSIENT_TIMEOUT },
|
|
470
|
+
{ ok: false, reason: "会话过期" },
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
assert(
|
|
474
|
+
obs.circuitBreak === false,
|
|
475
|
+
"E9 重试中途号死 → 不误入熔断",
|
|
476
|
+
);
|
|
477
|
+
assert(
|
|
478
|
+
obs.switched === false && obs.release === 1,
|
|
479
|
+
"E9 弃号换下一个候选(恰好 release 一次)",
|
|
480
|
+
);
|
|
481
|
+
assert(
|
|
482
|
+
obs.finalReason === "会话过期" && obs.finalAttempts === 2,
|
|
483
|
+
"E9 弃号发生在共 2 次尝试之后(中途号死停止重试)",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── 常量契约 ──
|
|
489
|
+
{
|
|
490
|
+
console.error("\n[F] 常量契约");
|
|
491
|
+
assert(CANDIDATE_INJECT_MAX_RETRIES === 2, "原号重试上限 = 2(共 3 次尝试)");
|
|
492
|
+
assert(CANDIDATE_INJECT_DELAY_MS === 15 * 1000, "重试间隔 = 15s");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
496
|
+
process.exit(fail > 0 ? 1 : 0);
|