tt-help-cli-ycl 1.4.183 → 1.4.184
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-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-faceban-e2e.mjs +131 -0
- package/scripts/test-login-pool-faceban.mjs +235 -0
- package/scripts/test-process-manager-label.mjs +145 -0
- package/src/cli/explore.js +99 -17
- package/src/cli/search.js +106 -15
- 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 +56 -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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 崩溃熔断三件套单测(F16 实录驱动,2026-11-04)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* 1. P1 崩溃签名分类 — isBrowserClosedError 命中 Target crashed / Page crashed
|
|
6
|
+
* (F16 三种实测形态),Target closed 等旧签名回归不受影响,账号级原因不误判
|
|
7
|
+
* 2. P3 恢复重试 — isRetryableRestoreFailure(net::ERR_* / 超时 / 崩溃 → true,
|
|
8
|
+
* 身份不符 / 会话过期 → false);retryRestoreOnNavFailure 3 次全败才弃号、
|
|
9
|
+
* 账号级不重试、中途成功即返、shouldAbort 中断
|
|
10
|
+
* 3. P2 熔断退避 — crashBackoffMs base / 2 倍递增 / max 钳制 / 清零(0 / 负数 / 非法)
|
|
11
|
+
* 4. P5 内存阈值 — resolveMemoryThreshold 默认回退 / 钳制 [0.5, 0.98] / HealthChecker 构造生效
|
|
12
|
+
*
|
|
13
|
+
* 跑法: node scripts/test-crash-circuit-breaker.mjs
|
|
14
|
+
*/
|
|
15
|
+
import assert from "assert";
|
|
16
|
+
import {
|
|
17
|
+
isBrowserClosedError,
|
|
18
|
+
isRetryableRestoreFailure,
|
|
19
|
+
} from "../src/lib/browser/page.js";
|
|
20
|
+
import {
|
|
21
|
+
crashBackoffMs,
|
|
22
|
+
retryRestoreOnNavFailure,
|
|
23
|
+
CRASH_BACKOFF_BASE_MS,
|
|
24
|
+
CRASH_BACKOFF_MAX_MS,
|
|
25
|
+
RESTORE_RETRY_MAX_RETRIES,
|
|
26
|
+
RESTORE_RETRY_DELAY_MS,
|
|
27
|
+
} from "../src/lib/login-pool-client.js";
|
|
28
|
+
import {
|
|
29
|
+
HealthChecker,
|
|
30
|
+
MEMORY_THRESHOLD,
|
|
31
|
+
MEMORY_THRESHOLD_MIN,
|
|
32
|
+
MEMORY_THRESHOLD_MAX,
|
|
33
|
+
resolveMemoryThreshold,
|
|
34
|
+
} from "../src/lib/browser/health-checker.js";
|
|
35
|
+
|
|
36
|
+
let pass = 0;
|
|
37
|
+
let fail = 0;
|
|
38
|
+
function check(name, actual, expected) {
|
|
39
|
+
try {
|
|
40
|
+
assert.strictEqual(actual, expected);
|
|
41
|
+
pass++;
|
|
42
|
+
console.log(` ✓ ${name}`);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
fail++;
|
|
45
|
+
console.error(` ✗ ${name}\n 期望: ${expected}\n 实际: ${actual}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 1. P1 崩溃签名分类 ──
|
|
50
|
+
console.log("\n规则 1:崩溃签名分类(isBrowserClosedError)");
|
|
51
|
+
// F16 实录三种实测形态必须命中
|
|
52
|
+
check(
|
|
53
|
+
"page.goto: Page crashed",
|
|
54
|
+
isBrowserClosedError(new Error("page.goto: Page crashed")),
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
check(
|
|
58
|
+
"page.addInitScript: Target crashed",
|
|
59
|
+
isBrowserClosedError(new Error("page.addInitScript: Target crashed")),
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
check(
|
|
63
|
+
"page.evaluate: Target crashed",
|
|
64
|
+
isBrowserClosedError(new Error("page.evaluate: Target crashed")),
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
// 旧签名回归不受影响
|
|
68
|
+
check(
|
|
69
|
+
"Target page, context or browser has been closed(旧)",
|
|
70
|
+
isBrowserClosedError(new Error("Target page, context or browser has been closed")),
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
check("Target closed(旧)", isBrowserClosedError(new Error("Target closed")), true);
|
|
74
|
+
check(
|
|
75
|
+
"net::ERR_CONNECTION_REFUSED(旧)",
|
|
76
|
+
isBrowserClosedError(new Error("page.goto: net::ERR_CONNECTION_REFUSED at https://www.tiktok.com/")),
|
|
77
|
+
true,
|
|
78
|
+
);
|
|
79
|
+
// 账号级/瞬态原因不得误判为浏览器级
|
|
80
|
+
check(
|
|
81
|
+
"身份不符(账号级)",
|
|
82
|
+
isBrowserClosedError(new Error("身份不符(期望 @a,实际 @b)")),
|
|
83
|
+
false,
|
|
84
|
+
);
|
|
85
|
+
check("会话过期(账号级)", isBrowserClosedError(new Error("会话过期")), false);
|
|
86
|
+
check(
|
|
87
|
+
"Timeout 30000ms exceeded(瞬态)",
|
|
88
|
+
isBrowserClosedError(new Error("Timeout 30000ms exceeded")),
|
|
89
|
+
false,
|
|
90
|
+
);
|
|
91
|
+
check("null/undefined", isBrowserClosedError(null) || isBrowserClosedError(undefined), false);
|
|
92
|
+
|
|
93
|
+
// ── 2. P3 恢复重试 ──
|
|
94
|
+
console.log("\n规则 2:恢复失败可重试分类(isRetryableRestoreFailure)");
|
|
95
|
+
// F16 实录:一次即弃号的恢复失败签名
|
|
96
|
+
check(
|
|
97
|
+
"net::ERR_HTTP_RESPONSE_CODE_FAILURE(F16 弃号签名)",
|
|
98
|
+
isRetryableRestoreFailure(
|
|
99
|
+
"导航失败: page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE at https://www.tiktok.com/",
|
|
100
|
+
),
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
check(
|
|
104
|
+
"net::ERR_TIMED_OUT",
|
|
105
|
+
isRetryableRestoreFailure("导航失败: page.goto: net::ERR_TIMED_OUT"),
|
|
106
|
+
true,
|
|
107
|
+
);
|
|
108
|
+
check(
|
|
109
|
+
"Timeout 45000ms exceeded",
|
|
110
|
+
isRetryableRestoreFailure("导航失败: page.goto: Timeout 45000ms exceeded"),
|
|
111
|
+
true,
|
|
112
|
+
);
|
|
113
|
+
check(
|
|
114
|
+
"Target crashed(恢复途中崩溃)",
|
|
115
|
+
isRetryableRestoreFailure("page.evaluate: Target crashed"),
|
|
116
|
+
true,
|
|
117
|
+
);
|
|
118
|
+
check("身份不符(账号级不重试)", isRetryableRestoreFailure("身份不符(期望 @a,实际 @b)"), false);
|
|
119
|
+
check("会话过期(账号级不重试)", isRetryableRestoreFailure("会话过期"), false);
|
|
120
|
+
check("无账号数据(账号级不重试)", isRetryableRestoreFailure("无账号数据"), false);
|
|
121
|
+
check("空/空串", isRetryableRestoreFailure(null) || isRetryableRestoreFailure("") || isRetryableRestoreFailure(undefined), false);
|
|
122
|
+
|
|
123
|
+
console.log("\n规则 2b:恢复重试驱动(retryRestoreOnNavFailure)");
|
|
124
|
+
const NAV_FAIL = "导航失败: page.goto: net::ERR_HTTP_RESPONSE_CODE_FAILURE";
|
|
125
|
+
const ACCT_FAIL = "身份不符(期望 @a,实际 @b)";
|
|
126
|
+
async function drive(script, firstFailure, opts = {}) {
|
|
127
|
+
let calls = 0;
|
|
128
|
+
let sleeps = 0;
|
|
129
|
+
const r = await retryRestoreOnNavFailure(
|
|
130
|
+
() => {
|
|
131
|
+
calls++;
|
|
132
|
+
const step = script[Math.min(calls - 1, script.length - 1)];
|
|
133
|
+
return typeof step === "string" ? { ok: false, reason: step } : step;
|
|
134
|
+
},
|
|
135
|
+
firstFailure,
|
|
136
|
+
{
|
|
137
|
+
isRetryable: isRetryableRestoreFailure,
|
|
138
|
+
sleep: async () => {
|
|
139
|
+
sleeps++;
|
|
140
|
+
},
|
|
141
|
+
...opts,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
return { r, calls, sleeps };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
const { r, calls, sleeps } = await drive([NAV_FAIL, NAV_FAIL, NAV_FAIL], NAV_FAIL);
|
|
149
|
+
check("3 次全败 → ok:false", r.ok, false);
|
|
150
|
+
check("3 次全败 → attempts=3", r.attempts, 3);
|
|
151
|
+
check("3 次全败 → attemptFn 调 2 次(首败不重调)", calls, 2);
|
|
152
|
+
check("3 次全败 → sleep 2 次(maxRetries=2)", sleeps, RESTORE_RETRY_MAX_RETRIES);
|
|
153
|
+
}
|
|
154
|
+
{
|
|
155
|
+
const { r, calls, sleeps } = await drive([], ACCT_FAIL);
|
|
156
|
+
check("账号级首败 → 不重试 attempts=1", r.attempts, 1);
|
|
157
|
+
check("账号级首败 → attemptFn 0 次", calls, 0);
|
|
158
|
+
check("账号级首败 → sleep 0 次", sleeps, 0);
|
|
159
|
+
check("账号级首败 → 原因透传", r.reason, ACCT_FAIL);
|
|
160
|
+
}
|
|
161
|
+
{
|
|
162
|
+
const { r, calls } = await drive([{ ok: true, username: "@ok" }], NAV_FAIL);
|
|
163
|
+
check("第 2 次成功 → ok:true", r.ok, true);
|
|
164
|
+
check("第 2 次成功 → attempts=2", r.attempts, 2);
|
|
165
|
+
check("第 2 次成功 → attemptFn 调 1 次", calls, 1);
|
|
166
|
+
}
|
|
167
|
+
{
|
|
168
|
+
// 第 2 次转账号级失败 → 停止重试
|
|
169
|
+
const { r, attempts2 } = await drive([ACCT_FAIL], NAV_FAIL);
|
|
170
|
+
check("中途转账号级 → 停止", r.ok, false);
|
|
171
|
+
check("中途转账号级 → attempts=2", r.attempts, 2);
|
|
172
|
+
check("中途转账号级 → 原因为账号级", r.reason, ACCT_FAIL);
|
|
173
|
+
}
|
|
174
|
+
{
|
|
175
|
+
let abortChecked = false;
|
|
176
|
+
const r = await retryRestoreOnNavFailure(() => ({ ok: false, reason: NAV_FAIL }), NAV_FAIL, {
|
|
177
|
+
isRetryable: isRetryableRestoreFailure,
|
|
178
|
+
sleep: async () => {},
|
|
179
|
+
shouldAbort: () => {
|
|
180
|
+
abortChecked = true;
|
|
181
|
+
return true;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
check("shouldAbort 中断 → aborted:true", r.aborted, true);
|
|
185
|
+
check("shouldAbort 中断 → 不再尝试", abortChecked, true);
|
|
186
|
+
}
|
|
187
|
+
check(
|
|
188
|
+
"重试常量:maxRetries=2 / delay=10s",
|
|
189
|
+
RESTORE_RETRY_MAX_RETRIES === 2 && RESTORE_RETRY_DELAY_MS === 10000,
|
|
190
|
+
true,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// ── 3. P2 熔断退避轮转 ──
|
|
194
|
+
console.log("\n规则 3:崩溃熔断退避(crashBackoffMs)");
|
|
195
|
+
check("BASE=60s", CRASH_BACKOFF_BASE_MS, 60000);
|
|
196
|
+
check("MAX=15min", CRASH_BACKOFF_MAX_MS, 900000);
|
|
197
|
+
check("第 0 轮 → 0(未熔断)", crashBackoffMs(0), 0);
|
|
198
|
+
check("第 1 轮 → 60s", crashBackoffMs(1), 60000);
|
|
199
|
+
check("第 2 轮 → 120s", crashBackoffMs(2), 120000);
|
|
200
|
+
check("第 3 轮 → 240s(2 倍递增)", crashBackoffMs(3), 240000);
|
|
201
|
+
check("第 4 轮 → 480s", crashBackoffMs(4), 480000);
|
|
202
|
+
check("第 5 轮 → 960s → 钳到 MAX 900s", crashBackoffMs(5), 900000);
|
|
203
|
+
check("第 100 轮 → MAX", crashBackoffMs(100), CRASH_BACKOFF_MAX_MS);
|
|
204
|
+
check("负数 → 0", crashBackoffMs(-1), 0);
|
|
205
|
+
check("非整数 → 0", crashBackoffMs(1.5), 0);
|
|
206
|
+
check("自定义 base/max", crashBackoffMs(2, 1000, 2500), 2000);
|
|
207
|
+
check("清零语义:轮数 0 → 0(成功后重算)", crashBackoffMs(0), 0);
|
|
208
|
+
|
|
209
|
+
// ── 4. P5 内存阈值可配置 ──
|
|
210
|
+
console.log("\n规则 4:内存阈值解析(resolveMemoryThreshold)");
|
|
211
|
+
check("缺省 → 默认 0.95", resolveMemoryThreshold(undefined), 0.95);
|
|
212
|
+
check("null → 默认 0.95", resolveMemoryThreshold(null), 0.95);
|
|
213
|
+
check("空串 → 默认 0.95", resolveMemoryThreshold(""), 0.95);
|
|
214
|
+
check("非数值 → 默认 0.95", resolveMemoryThreshold("abc"), 0.95);
|
|
215
|
+
check("默认常量 MEMORY_THRESHOLD=0.95", MEMORY_THRESHOLD, 0.95);
|
|
216
|
+
check("0.9 原样通过", resolveMemoryThreshold(0.9), 0.9);
|
|
217
|
+
check("0.3 → 下限 0.5", resolveMemoryThreshold(0.3), MEMORY_THRESHOLD_MIN);
|
|
218
|
+
check("0.99 → 上限 0.98", resolveMemoryThreshold(0.99), MEMORY_THRESHOLD_MAX);
|
|
219
|
+
check("1.5 → 上限 0.98", resolveMemoryThreshold(1.5), 0.98);
|
|
220
|
+
check("0.5 → 边界原样", resolveMemoryThreshold(0.5), 0.5);
|
|
221
|
+
check("0.98 → 边界原样", resolveMemoryThreshold(0.98), 0.98);
|
|
222
|
+
check("字符串数值 '0.9' 可解析", resolveMemoryThreshold("0.9"), 0.9);
|
|
223
|
+
check(
|
|
224
|
+
"钳制边界常量 0.5/0.98",
|
|
225
|
+
MEMORY_THRESHOLD_MIN === 0.5 && MEMORY_THRESHOLD_MAX === 0.98,
|
|
226
|
+
true,
|
|
227
|
+
);
|
|
228
|
+
check(
|
|
229
|
+
"HealthChecker 构造生效(0.9)",
|
|
230
|
+
new HealthChecker({ memoryThreshold: 0.9 }).memoryThreshold,
|
|
231
|
+
0.9,
|
|
232
|
+
);
|
|
233
|
+
check(
|
|
234
|
+
"HealthChecker 构造缺省 → 0.95",
|
|
235
|
+
new HealthChecker({}).memoryThreshold,
|
|
236
|
+
0.95,
|
|
237
|
+
);
|
|
238
|
+
check(
|
|
239
|
+
"HealthChecker 构造非法 → 0.95",
|
|
240
|
+
new HealthChecker({ memoryThreshold: "junk" }).memoryThreshold,
|
|
241
|
+
0.95,
|
|
242
|
+
);
|
|
243
|
+
check(
|
|
244
|
+
"HealthChecker 构造越界 → 钳制",
|
|
245
|
+
new HealthChecker({ memoryThreshold: 0.2 }).memoryThreshold,
|
|
246
|
+
0.5,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
console.log(`\n═══ 结果: ${pass} 通过, ${fail} 失败 ═══`);
|
|
250
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 错误任务自动恢复(B 方案)单测:listStaleErrorJobs + resetJob 回流
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* 1. commitJob 错误结果 → 行落 jobs 表(status='error',updated_at 盖章)
|
|
6
|
+
* 2. 龄期过滤:新鲜 error 不列出,超龄 error 列出
|
|
7
|
+
* 3. limit 参数生效
|
|
8
|
+
* 4. resetJob 回流:行回 task_queue(pending)、jobs 行消失、error 清空
|
|
9
|
+
* 5. 回流后 listStaleErrorJobs 不再列出
|
|
10
|
+
*
|
|
11
|
+
* 运行:node scripts/test-error-auto-reset.mjs
|
|
12
|
+
*/
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { createStore } from "../src/watch/data-store.js";
|
|
17
|
+
|
|
18
|
+
const MIN = 60 * 1000;
|
|
19
|
+
let pass = 0;
|
|
20
|
+
let fail = 0;
|
|
21
|
+
|
|
22
|
+
function assert(cond, label) {
|
|
23
|
+
if (cond) {
|
|
24
|
+
pass++;
|
|
25
|
+
console.error(` ✓ ${label}`);
|
|
26
|
+
} else {
|
|
27
|
+
fail++;
|
|
28
|
+
console.error(` ✗ ${label}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tmp = mkdtempSync(join(tmpdir(), "err-auto-reset-"));
|
|
33
|
+
const dbPath = join(tmp, "test.db");
|
|
34
|
+
const store = createStore(dbPath);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// 造数:直接向 task_queue 热表插 pending 行(addUser 只写 users/jobs_base 基线,不产热行)
|
|
38
|
+
const { addJobToDb } = await import("../src/watch/db-crud.js");
|
|
39
|
+
addJobToDb({
|
|
40
|
+
uniqueId: "user_a",
|
|
41
|
+
nickname: "A",
|
|
42
|
+
status: "pending",
|
|
43
|
+
sources: ["test"],
|
|
44
|
+
});
|
|
45
|
+
const before = store.getUser("user_a");
|
|
46
|
+
assert(before && before.status === "pending", "task_queue 造数后为 pending");
|
|
47
|
+
|
|
48
|
+
const commitRet = store.commitJob("user_a", { error: "测试错误" });
|
|
49
|
+
assert(commitRet && commitRet.saved === true, "commitJob 提交错误成功");
|
|
50
|
+
assert(commitRet.status === "error", "提交错误后 status=error");
|
|
51
|
+
|
|
52
|
+
const errJob = store.getUser("user_a");
|
|
53
|
+
assert(errJob.status === "error" && errJob.error, "error 行状态与错误旗标正确(error 列为 0/1 布尔,文本不落库)");
|
|
54
|
+
|
|
55
|
+
// ── 2. 龄期过滤 ──
|
|
56
|
+
const fresh = store.listStaleErrorJobs(10 * MIN, 500);
|
|
57
|
+
assert(!fresh.includes("user_a"), "新鲜 error(刚盖 updated_at)不被列出");
|
|
58
|
+
|
|
59
|
+
const { getDb } = await import("../src/watch/db-schema.js");
|
|
60
|
+
getDb()
|
|
61
|
+
.prepare(`UPDATE jobs SET updated_at = ? WHERE unique_id = 'user_a'`)
|
|
62
|
+
.run(Date.now() - 11 * MIN);
|
|
63
|
+
const stale = store.listStaleErrorJobs(10 * MIN, 500);
|
|
64
|
+
assert(stale.includes("user_a"), "超龄 error(11min 前)被列出");
|
|
65
|
+
|
|
66
|
+
// ── 3. limit 生效 ──
|
|
67
|
+
const limited = store.listStaleErrorJobs(10 * MIN, 0);
|
|
68
|
+
assert(limited.length === 0, "limit=0 不列出任何行");
|
|
69
|
+
|
|
70
|
+
// ── 4. resetJob 回流 ──
|
|
71
|
+
const resetRet = store.resetJob("user_a");
|
|
72
|
+
assert(resetRet && resetRet.saved === true, "resetJob 回流成功");
|
|
73
|
+
|
|
74
|
+
const backJob = store.getUser("user_a");
|
|
75
|
+
assert(backJob.status === "pending", "回流后 status=pending");
|
|
76
|
+
assert(!backJob.error, "回流后 error 已清空");
|
|
77
|
+
const { getUserTable } = await import("../src/watch/db-crud.js");
|
|
78
|
+
assert(getUserTable("user_a") === "task_queue", "回流行回到 task_queue 热表");
|
|
79
|
+
|
|
80
|
+
const jobsRow = getDb()
|
|
81
|
+
.prepare(`SELECT 1 FROM jobs WHERE unique_id = 'user_a'`)
|
|
82
|
+
.get();
|
|
83
|
+
assert(!jobsRow, "jobs 表中该行已迁出");
|
|
84
|
+
|
|
85
|
+
// ── 5. 回流后不再列出 ──
|
|
86
|
+
const after = store.listStaleErrorJobs(10 * MIN, 500);
|
|
87
|
+
assert(!after.includes("user_a"), "回流后不再被列出");
|
|
88
|
+
|
|
89
|
+
// ── 6. 无 DB(JSON 模式)安全退化 ──
|
|
90
|
+
const { closeDb } = await import("../src/watch/db-schema.js");
|
|
91
|
+
if (typeof closeDb === "function") {
|
|
92
|
+
closeDb();
|
|
93
|
+
const noDb = store.listStaleErrorJobs(10 * MIN, 500);
|
|
94
|
+
assert(Array.isArray(noDb) && noDb.length === 0, "无 DB 时返回空数组不抛错");
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
try {
|
|
98
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.error(`\n结果: ${pass} 通过 / ${fail} 失败`);
|
|
103
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端错误上报封装(方案甲)单测:
|
|
3
|
+
*
|
|
4
|
+
* A. createErrorReporter 字段附加
|
|
5
|
+
* 1. poolUsername 从 getState 注入(有在手号/无在手号空串)
|
|
6
|
+
* 2. proxyNode 三级 fallback:端口匹配通道 → 主通道 → 空串
|
|
7
|
+
* 3. proxyUrl 缺省回退 ~/.tt-help.json 的 proxy(注入式验证 parseProxyPort)
|
|
8
|
+
* 4. 旧 username 原样透传;userId 单次覆盖(search 每轮标签 clientId 场景)
|
|
9
|
+
* 5. 缺省字段兜底(errorType 默认 other)
|
|
10
|
+
* B. 服务端 reportClientError 新字段存列
|
|
11
|
+
* 6. 首次上报落 poolUsername/proxyNode
|
|
12
|
+
* 7. 非空才覆盖语义(空串不覆盖旧值)
|
|
13
|
+
* 8. captcha 分支也不丢新字段
|
|
14
|
+
*
|
|
15
|
+
* 运行:node scripts/test-error-report.mjs
|
|
16
|
+
*/
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { createErrorReporter, parseProxyPort, resolveProxyNode } from "../src/lib/error-report.js";
|
|
21
|
+
import { createStore } from "../src/watch/data-store.js";
|
|
22
|
+
|
|
23
|
+
let pass = 0;
|
|
24
|
+
let fail = 0;
|
|
25
|
+
|
|
26
|
+
function assert(cond, label) {
|
|
27
|
+
if (cond) {
|
|
28
|
+
pass++;
|
|
29
|
+
console.error(` ✓ ${label}`);
|
|
30
|
+
} else {
|
|
31
|
+
fail++;
|
|
32
|
+
console.error(` ✗ ${label}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── A0. parseProxyPort ──
|
|
37
|
+
{
|
|
38
|
+
console.error("\n[parseProxyPort]");
|
|
39
|
+
assert(parseProxyPort("http://127.0.0.1:7897") === 7897, "http URL 提取端口");
|
|
40
|
+
assert(parseProxyPort("socks5://localhost:1080") === 1080, "socks5 URL 提取端口");
|
|
41
|
+
assert(parseProxyPort("127.0.0.1:7890") === 7890, "非 URL 形态正则兑底");
|
|
42
|
+
assert(parseProxyPort("") === 0, "空串 → 0");
|
|
43
|
+
assert(parseProxyPort(null) === 0, "null → 0");
|
|
44
|
+
assert(parseProxyPort("http://127.0.0.1") === 0, "无端口 → 0");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── A1/A2. resolveProxyNode 三级 fallback(deps 注入,不依赖真机文件) ──
|
|
48
|
+
{
|
|
49
|
+
console.error("\n[resolveProxyNode 三级 fallback]");
|
|
50
|
+
const channels = [
|
|
51
|
+
{ port: 7897, node: "端口匹配节点", stale: false },
|
|
52
|
+
{ port: 7898, node: "其他通道节点", stale: true }, // stale 通道不参与匹配
|
|
53
|
+
];
|
|
54
|
+
const health = { node: "主通道节点", stale: false };
|
|
55
|
+
|
|
56
|
+
assert(
|
|
57
|
+
resolveProxyNode("http://127.0.0.1:7897", { channels, health }) ===
|
|
58
|
+
"端口匹配节点",
|
|
59
|
+
"L1 业务端口匹配非 stale 通道的 node",
|
|
60
|
+
);
|
|
61
|
+
assert(
|
|
62
|
+
resolveProxyNode("http://127.0.0.1:7899", { channels, health }) ===
|
|
63
|
+
"主通道节点",
|
|
64
|
+
"L2 端口无匹配 → 主通道 node",
|
|
65
|
+
);
|
|
66
|
+
assert(
|
|
67
|
+
resolveProxyNode("", { channels, health }) === "主通道节点",
|
|
68
|
+
"无代理地址 → 跳过 L1 直接主通道",
|
|
69
|
+
);
|
|
70
|
+
assert(
|
|
71
|
+
resolveProxyNode("http://127.0.0.1:7899", {
|
|
72
|
+
channels,
|
|
73
|
+
health: { node: "主通道节点", stale: true },
|
|
74
|
+
}) === "",
|
|
75
|
+
"L3 主通道 stale → 空串",
|
|
76
|
+
);
|
|
77
|
+
assert(
|
|
78
|
+
resolveProxyNode("http://127.0.0.1:7897", {
|
|
79
|
+
channels: [{ port: 7897, node: "x", stale: true }],
|
|
80
|
+
health: null,
|
|
81
|
+
}) === "",
|
|
82
|
+
"L3 通道仅 stale + 无主通道 → 空串",
|
|
83
|
+
);
|
|
84
|
+
assert(
|
|
85
|
+
resolveProxyNode("http://127.0.0.1:7897", { channels: [], health: null }) ===
|
|
86
|
+
"",
|
|
87
|
+
"L3 无 clash(空通道/null health)→ 空串",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── A3~A5. createErrorReporter 字段附加 ──
|
|
92
|
+
{
|
|
93
|
+
console.error("\n[createErrorReporter 字段附加]");
|
|
94
|
+
const captured = [];
|
|
95
|
+
const apiPost = async (url, body) => {
|
|
96
|
+
captured.push({ url, body });
|
|
97
|
+
return { ok: true };
|
|
98
|
+
};
|
|
99
|
+
let poolUsername = "pool_user_01";
|
|
100
|
+
const reporter = createErrorReporter({
|
|
101
|
+
apiPost,
|
|
102
|
+
serverUrl: "http://127.0.0.1:3001",
|
|
103
|
+
userId: "machine_user",
|
|
104
|
+
proxyUrl: "http://127.0.0.1:7897",
|
|
105
|
+
getState: () => ({ poolUsername }),
|
|
106
|
+
});
|
|
107
|
+
const channels = [{ port: 7897, node: "通道节点N1", stale: false }];
|
|
108
|
+
const health = { node: "主通道节点", stale: false };
|
|
109
|
+
|
|
110
|
+
await reporter.report({
|
|
111
|
+
errorType: "captcha",
|
|
112
|
+
errorMessage: "页面出现验证码",
|
|
113
|
+
stage: "video-page",
|
|
114
|
+
username: "target_user",
|
|
115
|
+
// deps 注入走 report 内部 resolveProxyNode —— 无法从外部传,
|
|
116
|
+
// 这里用真文件环境不可控,改为直接校验 resolveProxyNode 已单测;
|
|
117
|
+
// report 层只断言 poolUsername/userId/透传/兜底
|
|
118
|
+
});
|
|
119
|
+
const b1 = captured[0].body;
|
|
120
|
+
assert(captured[0].url === "http://127.0.0.1:3001/api/error-report", "POST 地址正确");
|
|
121
|
+
assert(b1.userId === "machine_user", "userId 取工厂注入值");
|
|
122
|
+
assert(b1.username === "target_user", "旧 username 原样透传");
|
|
123
|
+
assert(b1.errorType === "captcha" && b1.stage === "video-page", "业务字段透传");
|
|
124
|
+
assert(typeof b1.poolUsername === "string", "poolUsername 字段存在");
|
|
125
|
+
assert(typeof b1.proxyNode === "string", "proxyNode 字段存在");
|
|
126
|
+
|
|
127
|
+
await reporter.report({ errorType: "network" });
|
|
128
|
+
const b2 = captured[1].body;
|
|
129
|
+
assert(b2.userId === "machine_user", "未传 username 时其余字段仍完整");
|
|
130
|
+
|
|
131
|
+
await reporter.report({
|
|
132
|
+
userId: "per-tag-client-id",
|
|
133
|
+
errorType: "other",
|
|
134
|
+
username: "search_tag",
|
|
135
|
+
});
|
|
136
|
+
assert(
|
|
137
|
+
captured[2].body.userId === "per-tag-client-id",
|
|
138
|
+
"userId 单次覆盖(search 每轮标签 clientId 场景)",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
poolUsername = "";
|
|
142
|
+
await reporter.report({ errorType: "other" });
|
|
143
|
+
assert(
|
|
144
|
+
captured[3].body.poolUsername === "",
|
|
145
|
+
"无在手号 → poolUsername 空串(不传 unknown)",
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// getState 未注入也不炸
|
|
149
|
+
const bare = createErrorReporter({ apiPost, serverUrl: "http://x", userId: "u" });
|
|
150
|
+
await bare.report({ errorType: "other" });
|
|
151
|
+
assert(captured[4].body.poolUsername === "", "无 getState → poolUsername 空串兜底");
|
|
152
|
+
|
|
153
|
+
let threw = false;
|
|
154
|
+
try {
|
|
155
|
+
createErrorReporter({ serverUrl: "http://x", userId: "u" });
|
|
156
|
+
} catch {
|
|
157
|
+
threw = true;
|
|
158
|
+
}
|
|
159
|
+
assert(threw, "缺 apiPost 抛错(防止静默漏报)");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── B. 服务端 reportClientError 存列 ──
|
|
163
|
+
{
|
|
164
|
+
console.error("\n[reportClientError 新字段存列]");
|
|
165
|
+
const tmp = mkdtempSync(join(tmpdir(), "err-report-"));
|
|
166
|
+
const store = createStore(join(tmp, "test.db"));
|
|
167
|
+
try {
|
|
168
|
+
store.reportClientError(
|
|
169
|
+
"node_a", "other", "业务错误", "legacy_user", "process", "stack",
|
|
170
|
+
"host-a", "F-1@HOST", "pool_user_01", "通道节点N1",
|
|
171
|
+
);
|
|
172
|
+
let e = store.getClientErrors().find((c) => c.userId === "node_a");
|
|
173
|
+
assert(e && e.poolUsername === "pool_user_01", "首次上报落 poolUsername");
|
|
174
|
+
assert(e && e.proxyNode === "通道节点N1", "首次上报落 proxyNode");
|
|
175
|
+
assert(e && e.username === "legacy_user", "旧 username 语义不动");
|
|
176
|
+
|
|
177
|
+
// 非空才覆盖:空串不冲掉旧值
|
|
178
|
+
store.reportClientError(
|
|
179
|
+
"node_a", "network", "超时", "", "process", "",
|
|
180
|
+
"host-a", "F-1@HOST", "", "",
|
|
181
|
+
);
|
|
182
|
+
e = store.getClientErrors().find((c) => c.userId === "node_a");
|
|
183
|
+
assert(e.poolUsername === "pool_user_01", "空 poolUsername 不覆盖旧值");
|
|
184
|
+
assert(e.proxyNode === "通道节点N1", "空 proxyNode 不覆盖旧值");
|
|
185
|
+
assert(e.errorType === "network", "常规字段仍按原语义覆盖");
|
|
186
|
+
|
|
187
|
+
// 非空新值覆盖
|
|
188
|
+
store.reportClientError(
|
|
189
|
+
"node_a", "network", "超时2", "", "process", "",
|
|
190
|
+
"host-a", "F-1@HOST", "pool_user_02", "通道节点N2",
|
|
191
|
+
);
|
|
192
|
+
e = store.getClientErrors().find((c) => c.userId === "node_a");
|
|
193
|
+
assert(e.poolUsername === "pool_user_02", "非空 poolUsername 覆盖");
|
|
194
|
+
assert(e.proxyNode === "通道节点N2", "非空 proxyNode 覆盖");
|
|
195
|
+
|
|
196
|
+
// captcha 分支(走计数路径)同样落新字段
|
|
197
|
+
store.reportClientError(
|
|
198
|
+
"node_b", "captcha", "验证码", "", "between-tasks", "",
|
|
199
|
+
"host-b", "F-2@HOST", "pool_user_03", "主通道节点",
|
|
200
|
+
);
|
|
201
|
+
const b = store.getClientErrors().find((c) => c.userId === "node_b");
|
|
202
|
+
assert(b && b.captchaCount === 1 && b.poolUsername === "pool_user_03" && b.proxyNode === "主通道节点",
|
|
203
|
+
"captcha 分支落新字段且计数路径不受影响");
|
|
204
|
+
|
|
205
|
+
// 旧版客户端(不带新字段,undefined 参数)不炸、落空串
|
|
206
|
+
store.reportClientError("node_c", "other", "旧客户端", "u", "process", "", "h", "c");
|
|
207
|
+
const c = store.getClientErrors().find((r) => r.userId === "node_c");
|
|
208
|
+
assert(c && c.poolUsername === "" && c.proxyNode === "", "旧客户端兼容:缺参落空串");
|
|
209
|
+
} finally {
|
|
210
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
215
|
+
process.exit(fail > 0 ? 1 : 0);
|