@lancewu602/mihomo-dash 0.0.0-stage → 0.6.1
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/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/mihomo-dash.js +4 -0
- package/package.json +63 -4
- package/server/CHANGELOG.md +51 -0
- package/server/package.json +13 -0
- package/server/src/cli/index.js +75 -0
- package/server/src/cli/service.js +167 -0
- package/server/src/cli/status.js +245 -0
- package/server/src/http/config-files.js +148 -0
- package/server/src/http/config-order.js +186 -0
- package/server/src/http/config-sections.js +278 -0
- package/server/src/http/default-config.js +69 -0
- package/server/src/http/groups.js +386 -0
- package/server/src/http/index.js +260 -0
- package/server/src/http/kernel-admin.js +522 -0
- package/server/src/http/kernel-proxy.js +43 -0
- package/server/src/http/providers.js +251 -0
- package/server/src/http/rules.js +509 -0
- package/server/src/http/settings.js +409 -0
- package/server/src/http/system-proxy.js +86 -0
- package/server/src/index.js +26 -0
- package/server/src/kernel/archive.js +181 -0
- package/server/src/kernel/binary.js +33 -0
- package/server/src/kernel/client.js +138 -0
- package/server/src/kernel/install.js +150 -0
- package/server/src/kernel/lifecycle.js +339 -0
- package/server/src/kernel/run.js +11 -0
- package/server/src/shared/args.js +57 -0
- package/server/src/shared/atomic-file.js +41 -0
- package/server/src/shared/exec.js +44 -0
- package/server/src/shared/kernel-config.js +27 -0
- package/server/src/shared/log-file.js +92 -0
- package/server/src/shared/panel.js +58 -0
- package/server/src/shared/paths.js +72 -0
- package/server/src/shared/service.js +552 -0
- package/server/src/shared/system-proxy.js +375 -0
- package/server/src/shared/text.js +11 -0
- package/server/tests/cli-service.test.js +153 -0
- package/server/tests/config-dir.test.js +133 -0
- package/server/tests/config-order.test.js +148 -0
- package/server/tests/groups.test.js +558 -0
- package/server/tests/kernel-admin.test.js +647 -0
- package/server/tests/kernel.test.js +250 -0
- package/server/tests/log-file.test.js +110 -0
- package/server/tests/providers.test.js +259 -0
- package/server/tests/rules.test.js +463 -0
- package/server/tests/service.test.js +470 -0
- package/server/tests/settings.test.js +456 -0
- package/server/tests/status.test.js +269 -0
- package/server/tests/system-proxy.test.js +497 -0
- package/server/tsconfig.json +17 -0
- package/web/dist/assets/index-BpDzYhuI.css +1 -0
- package/web/dist/assets/index-WmujD2EM.js +515 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 策略组增删(server/src/http/groups.js)集成测试。
|
|
3
|
+
* 用桩 HTTP 服务替代内核(只响应 PUT /configs),验证:文本改写保留注释、
|
|
4
|
+
* 引用检查(rules / sub-rules / 其它组)、内核重载失败时报错。运行:npm test
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { after, before, beforeEach, test } from 'node:test';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import yaml from 'js-yaml';
|
|
13
|
+
|
|
14
|
+
const BASE_CONFIG = `# Document: https://wiki.metacubex.one/config/
|
|
15
|
+
mixed-port: 7890
|
|
16
|
+
mode: rule
|
|
17
|
+
|
|
18
|
+
proxy-providers:
|
|
19
|
+
airport:
|
|
20
|
+
type: http
|
|
21
|
+
url: "https://example.com/link/aaa"
|
|
22
|
+
interval: 3600
|
|
23
|
+
path: ./providers/airport.yaml
|
|
24
|
+
|
|
25
|
+
proxy-groups:
|
|
26
|
+
- name: 节点选择
|
|
27
|
+
type: select
|
|
28
|
+
proxies: [自动选择]
|
|
29
|
+
use: [airport]
|
|
30
|
+
- name: 自动选择
|
|
31
|
+
type: url-test
|
|
32
|
+
use: [airport]
|
|
33
|
+
url: https://www.gstatic.com/generate_204
|
|
34
|
+
interval: 300
|
|
35
|
+
tolerance: 50
|
|
36
|
+
# 组注释
|
|
37
|
+
- name: 备用组
|
|
38
|
+
type: select
|
|
39
|
+
use: [airport]
|
|
40
|
+
|
|
41
|
+
rules:
|
|
42
|
+
# 规则注释
|
|
43
|
+
- GEOSITE,gfw,自动选择
|
|
44
|
+
- MATCH,节点选择
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/** @type {string} */ let tmpDir;
|
|
48
|
+
/** @type {string} */ let cfgDir;
|
|
49
|
+
/** @type {string} */ let cliDir;
|
|
50
|
+
/** @type {import('node:http').Server} */ let server;
|
|
51
|
+
/** @type {boolean} */ let kernelDown = false;
|
|
52
|
+
/** 桩内核收到的请求数(用于断言「纯写盘」的操作不触发内核请求) */
|
|
53
|
+
let kernelCalls = 0;
|
|
54
|
+
/** @type {typeof import('../src/http/groups.js')} */ let groups;
|
|
55
|
+
|
|
56
|
+
const configPath = () => path.join(cfgDir, 'config.yaml');
|
|
57
|
+
const readConfig = () => fs.readFileSync(configPath(), 'utf8');
|
|
58
|
+
/** 代码只认 config.yaml 的 external-controller(环境变量覆盖已去掉),桩内核地址必须写进配置 */
|
|
59
|
+
let stubPort = 0;
|
|
60
|
+
const withKernel = (text) =>
|
|
61
|
+
`${text}${text.endsWith('\n') ? '' : '\n'}external-controller: 127.0.0.1:${stubPort}\n`;
|
|
62
|
+
const writeConfig = (text) => fs.writeFileSync(configPath(), withKernel(text));
|
|
63
|
+
const parseConfig = () => yaml.load(readConfig());
|
|
64
|
+
const resetConfig = (text = BASE_CONFIG) => writeConfig(text);
|
|
65
|
+
|
|
66
|
+
before(async () => {
|
|
67
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-groups-'));
|
|
68
|
+
cfgDir = path.join(tmpDir, 'mihomo');
|
|
69
|
+
cliDir = path.join(tmpDir, 'cli');
|
|
70
|
+
fs.mkdirSync(path.join(cfgDir, 'providers'), { recursive: true });
|
|
71
|
+
|
|
72
|
+
server = http.createServer((_req, res) => {
|
|
73
|
+
kernelCalls++;
|
|
74
|
+
res.writeHead(kernelDown ? 500 : 200, { 'content-type': 'application/json' });
|
|
75
|
+
res.end(kernelDown ? JSON.stringify({ message: 'mock reload failure' }) : '{}');
|
|
76
|
+
});
|
|
77
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
78
|
+
|
|
79
|
+
// 必须在 import 之前注入环境变量:paths.js / kernel.js 在模块加载时读取
|
|
80
|
+
process.env.MIHOMO_DIR = cfgDir;
|
|
81
|
+
process.env.MIHOMO_DASH_DIR = cliDir;
|
|
82
|
+
stubPort = server.address().port;
|
|
83
|
+
groups = await import('../src/http/groups.js');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
after(() => {
|
|
87
|
+
server?.close();
|
|
88
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
kernelDown = false;
|
|
93
|
+
kernelCalls = 0;
|
|
94
|
+
resetConfig();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('新增策略组:写入 select 组(use + proxies)并保留注释与其余段落', async () => {
|
|
98
|
+
const beforeDoc = parseConfig();
|
|
99
|
+
const r = await groups.addGroup({
|
|
100
|
+
name: '备用选择',
|
|
101
|
+
type: 'select',
|
|
102
|
+
use: ['airport'],
|
|
103
|
+
proxies: ['自动选择', '日本 中继-1(1.5x)'],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
assert.equal(r.name, '备用选择');
|
|
107
|
+
|
|
108
|
+
const text = readConfig();
|
|
109
|
+
assert.match(text, /# Document: https:\/\/wiki\.metacubex\.one/);
|
|
110
|
+
assert.match(text, /# 组注释/);
|
|
111
|
+
assert.match(text, /# 规则注释/);
|
|
112
|
+
assert.match(text, /- name: 备用选择/);
|
|
113
|
+
|
|
114
|
+
const doc = parseConfig();
|
|
115
|
+
assert.deepEqual(doc['proxy-groups'].slice(0, 3), beforeDoc['proxy-groups']);
|
|
116
|
+
assert.deepEqual(doc['proxy-groups'][3], {
|
|
117
|
+
name: '备用选择',
|
|
118
|
+
type: 'select',
|
|
119
|
+
proxies: ['自动选择', '日本 中继-1(1.5x)'],
|
|
120
|
+
use: ['airport'],
|
|
121
|
+
});
|
|
122
|
+
assert.deepEqual(doc.rules, beforeDoc.rules);
|
|
123
|
+
assert.deepEqual(doc['proxy-providers'], beforeDoc['proxy-providers']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('新增策略组:url-test 默认 url/interval/tolerance,显式传值可覆盖', async () => {
|
|
127
|
+
await groups.addGroup({ name: '自动选择2', type: 'url-test', use: ['airport'] });
|
|
128
|
+
await groups.addGroup({
|
|
129
|
+
name: '自动选择3',
|
|
130
|
+
type: 'url-test',
|
|
131
|
+
use: ['airport'],
|
|
132
|
+
url: 'https://example.com/204',
|
|
133
|
+
interval: 120,
|
|
134
|
+
tolerance: 30,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const list = parseConfig()['proxy-groups'];
|
|
138
|
+
assert.deepEqual(
|
|
139
|
+
list.find((g) => g.name === '自动选择2'),
|
|
140
|
+
{
|
|
141
|
+
name: '自动选择2',
|
|
142
|
+
type: 'url-test',
|
|
143
|
+
use: ['airport'],
|
|
144
|
+
url: 'https://www.gstatic.com/generate_204',
|
|
145
|
+
interval: 300,
|
|
146
|
+
tolerance: 50,
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
list.find((g) => g.name === '自动选择3'),
|
|
151
|
+
{
|
|
152
|
+
name: '自动选择3',
|
|
153
|
+
type: 'url-test',
|
|
154
|
+
use: ['airport'],
|
|
155
|
+
url: 'https://example.com/204',
|
|
156
|
+
interval: 120,
|
|
157
|
+
tolerance: 30,
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('新增策略组:参数非法时拒绝且不改动配置', async () => {
|
|
163
|
+
const cases = [
|
|
164
|
+
[{ name: '节点选择', type: 'select', use: ['airport'] }, /已存在/],
|
|
165
|
+
[{ name: 'DIRECT', type: 'select', use: ['airport'] }, /保留名称/],
|
|
166
|
+
[{ name: 'x', type: 'fallback', use: ['airport'] }, /仅支持 select/],
|
|
167
|
+
[{ name: 'x', type: 'select' }, /至少选择一个/],
|
|
168
|
+
[{ name: 'x', type: 'select', proxies: [] }, /至少选择一个/],
|
|
169
|
+
[{ name: 'x', type: 'url-test', use: ['airport'], url: 'ftp://a' }, /http\(s\)/],
|
|
170
|
+
[{ name: 'x', type: 'select', proxies: ['x'] }, /不能引用自己/],
|
|
171
|
+
[{ name: '', type: 'select', use: ['airport'] }, /不能为空/],
|
|
172
|
+
[{ name: 'a:b', type: 'select', use: ['airport'] }, /不能包含/],
|
|
173
|
+
];
|
|
174
|
+
for (const [input, re] of cases) {
|
|
175
|
+
await assert.rejects(() => groups.addGroup(input), re);
|
|
176
|
+
assert.equal(readConfig(), withKernel(BASE_CONFIG), `配置被意外改动: ${JSON.stringify(input)}`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('新增策略组:无 proxy-groups 段时自动创建;空流式写法可追加', async () => {
|
|
181
|
+
resetConfig('mixed-port: 7891\nrules:\n - MATCH,DIRECT\n');
|
|
182
|
+
await groups.addGroup({ name: '新组', type: 'select', use: ['airport'] });
|
|
183
|
+
assert.deepEqual(parseConfig()['proxy-groups'], [
|
|
184
|
+
{ name: '新组', type: 'select', use: ['airport'] },
|
|
185
|
+
]);
|
|
186
|
+
assert.equal(parseConfig()['mixed-port'], 7891);
|
|
187
|
+
|
|
188
|
+
resetConfig('proxy-groups: []\nrules:\n - MATCH,DIRECT\n');
|
|
189
|
+
await groups.addGroup({ name: '新组', type: 'select', use: ['airport'] });
|
|
190
|
+
const doc = parseConfig();
|
|
191
|
+
assert.deepEqual(doc['proxy-groups'], [{ name: '新组', type: 'select', use: ['airport'] }]);
|
|
192
|
+
assert.match(readConfig(), /proxy-groups:\n {2}- name: 新组/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('删除策略组:被规则引用时拒绝,并列出引用位置', async () => {
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
() => groups.removeGroup('自动选择'),
|
|
198
|
+
(e) => {
|
|
199
|
+
assert.match(e.message, /仍被引用/);
|
|
200
|
+
assert.match(e.message, /rules: GEOSITE,gfw,自动选择/);
|
|
201
|
+
assert.match(e.message, /策略组「节点选择」的 proxies/);
|
|
202
|
+
assert.deepEqual(e.refs, ['策略组「节点选择」的 proxies', 'rules: GEOSITE,gfw,自动选择']);
|
|
203
|
+
return true;
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
assert.equal(readConfig(), withKernel(BASE_CONFIG));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('删除策略组:被 sub-rules 引用时拒绝', async () => {
|
|
210
|
+
resetConfig(`${BASE_CONFIG}\nsub-rules:\n 广告:\n - DOMAIN-SUFFIX,ads.com,备用组\n`);
|
|
211
|
+
await assert.rejects(
|
|
212
|
+
() => groups.removeGroup('备用组'),
|
|
213
|
+
(e) => {
|
|
214
|
+
assert.match(e.message, /sub-rules「广告」/);
|
|
215
|
+
assert.deepEqual(e.refs, ['sub-rules「广告」: DOMAIN-SUFFIX,ads.com,备用组']);
|
|
216
|
+
return true;
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('删除策略组:无引用时移除条目,保留注释与其它组', async () => {
|
|
222
|
+
const beforeDoc = parseConfig();
|
|
223
|
+
const r = await groups.removeGroup('备用组');
|
|
224
|
+
assert.equal(r.name, '备用组');
|
|
225
|
+
|
|
226
|
+
const text = readConfig();
|
|
227
|
+
assert.match(text, /# 组注释/);
|
|
228
|
+
assert.match(text, /# 规则注释/);
|
|
229
|
+
assert.doesNotMatch(text, /备用组/);
|
|
230
|
+
|
|
231
|
+
const doc = parseConfig();
|
|
232
|
+
assert.deepEqual(doc['proxy-groups'], beforeDoc['proxy-groups'].slice(0, 2));
|
|
233
|
+
assert.deepEqual(doc.rules, beforeDoc.rules);
|
|
234
|
+
// 删除后相邻条目之间不应留下空行
|
|
235
|
+
assert.doesNotMatch(text, /tolerance: 50\n\s*\n/);
|
|
236
|
+
assert.match(text, /- name: 自动选择\n(.*\n)*?\s+interval: 300\n\s+tolerance: 50\n/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('删除策略组:最后一个组时连同段落一起移除;未知名称报错', async () => {
|
|
240
|
+
resetConfig(
|
|
241
|
+
'proxy-groups:\n - name: 唯一组\n type: select\n use: [airport]\nrules:\n - MATCH,DIRECT\n',
|
|
242
|
+
);
|
|
243
|
+
await groups.removeGroup('唯一组');
|
|
244
|
+
const doc = parseConfig();
|
|
245
|
+
assert.equal(doc['proxy-groups'], undefined);
|
|
246
|
+
assert.deepEqual(doc.rules, ['MATCH,DIRECT']);
|
|
247
|
+
assert.match(readConfig(), /rules:\n {2}- MATCH,DIRECT/);
|
|
248
|
+
|
|
249
|
+
await assert.rejects(() => groups.removeGroup('不存在'), /未找到策略组/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('删除策略组:新增后即可删除(回归:删除范围的边界)', async () => {
|
|
253
|
+
await groups.addGroup({ name: '临时组', type: 'select', proxies: ['自动选择'] });
|
|
254
|
+
await groups.removeGroup('临时组');
|
|
255
|
+
const doc = parseConfig();
|
|
256
|
+
assert.equal(
|
|
257
|
+
doc['proxy-groups'].some((g) => g.name === '临时组'),
|
|
258
|
+
false,
|
|
259
|
+
);
|
|
260
|
+
assert.equal(doc['proxy-groups'].length, 3);
|
|
261
|
+
assert.match(readConfig(), /# 组注释/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('内核热重载失败时报错,config.yaml 停在新内容(新增与删除都一样)', async () => {
|
|
265
|
+
kernelDown = true;
|
|
266
|
+
await assert.rejects(
|
|
267
|
+
() => groups.addGroup({ name: '新组', type: 'select', use: ['airport'] }),
|
|
268
|
+
/热重载失败/,
|
|
269
|
+
);
|
|
270
|
+
assert.match(readConfig(), /name: 新组/);
|
|
271
|
+
|
|
272
|
+
await assert.rejects(() => groups.removeGroup('备用组'), /热重载失败/);
|
|
273
|
+
assert.doesNotMatch(readConfig(), /name: 备用组/);
|
|
274
|
+
kernelDown = false;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('读取策略组:返回 config.yaml 中的原始条目(含 use / proxies)', () => {
|
|
278
|
+
assert.deepEqual(groups.getGroup('节点选择'), {
|
|
279
|
+
name: '节点选择',
|
|
280
|
+
type: 'select',
|
|
281
|
+
proxies: ['自动选择'],
|
|
282
|
+
use: ['airport'],
|
|
283
|
+
});
|
|
284
|
+
assert.throws(() => groups.getGroup('不存在'), /未找到策略组/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('读取策略组列表:原样返回全部条目(含 interval / tolerance)', () => {
|
|
288
|
+
const list = groups.listGroups();
|
|
289
|
+
assert.deepEqual(list, yaml.load(BASE_CONFIG)['proxy-groups']);
|
|
290
|
+
const auto = list.find((g) => g.name === '自动选择');
|
|
291
|
+
assert.equal(auto.interval, 300);
|
|
292
|
+
assert.equal(auto.tolerance, 50);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('读取策略组列表:无 proxy-groups 段时返回空数组', () => {
|
|
296
|
+
resetConfig('mixed-port: 7891\n');
|
|
297
|
+
assert.deepEqual(groups.listGroups(), []);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('编辑策略组:就地替换条目、保留注释与其它组', async () => {
|
|
301
|
+
const beforeDoc = parseConfig();
|
|
302
|
+
const r = await groups.updateGroup('节点选择', {
|
|
303
|
+
type: 'url-test',
|
|
304
|
+
use: ['airport'],
|
|
305
|
+
url: 'https://example.com/204',
|
|
306
|
+
interval: 120,
|
|
307
|
+
tolerance: 20,
|
|
308
|
+
});
|
|
309
|
+
assert.equal(r.name, '节点选择');
|
|
310
|
+
|
|
311
|
+
const text = readConfig();
|
|
312
|
+
assert.match(text, /# Document: https:\/\/wiki\.metacubex\.one/);
|
|
313
|
+
assert.match(text, /# 组注释/);
|
|
314
|
+
assert.match(text, /# 规则注释/);
|
|
315
|
+
assert.doesNotMatch(text, /- name: 节点选择\n {4}type: select/);
|
|
316
|
+
|
|
317
|
+
const doc = parseConfig();
|
|
318
|
+
assert.deepEqual(doc['proxy-groups'][0], {
|
|
319
|
+
name: '节点选择',
|
|
320
|
+
type: 'url-test',
|
|
321
|
+
use: ['airport'],
|
|
322
|
+
url: 'https://example.com/204',
|
|
323
|
+
interval: 120,
|
|
324
|
+
tolerance: 20,
|
|
325
|
+
});
|
|
326
|
+
assert.deepEqual(doc['proxy-groups'].slice(1), beforeDoc['proxy-groups'].slice(1));
|
|
327
|
+
assert.deepEqual(doc.rules, beforeDoc.rules);
|
|
328
|
+
assert.match(text, /# 组注释\n {2}- name: 备用组/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('编辑 / 删除策略组:不吞掉条目后的注释(回归)', async () => {
|
|
332
|
+
// 自动选择 后面紧跟 `# 组注释` + 备用组;替换时注释必须保留
|
|
333
|
+
await groups.updateGroup('自动选择', { type: 'select', use: ['airport'] });
|
|
334
|
+
let text = readConfig();
|
|
335
|
+
assert.match(text, /# 组注释\n {2}- name: 备用组/);
|
|
336
|
+
assert.doesNotMatch(text, /- name: 自动选择\n {4}type: url-test/);
|
|
337
|
+
|
|
338
|
+
// 删除 自动选择 时同样保留注释
|
|
339
|
+
await groups.removeGroup('备用组');
|
|
340
|
+
text = readConfig();
|
|
341
|
+
assert.match(text, /# 组注释/);
|
|
342
|
+
assert.doesNotMatch(text, /备用组/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('编辑策略组:select 组不带测速字段;参数非法时拒绝且不改动配置', async () => {
|
|
346
|
+
await groups.updateGroup('自动选择', { type: 'select', proxies: ['备用组'] });
|
|
347
|
+
const entry = parseConfig()['proxy-groups'].find((g) => g.name === '自动选择');
|
|
348
|
+
assert.deepEqual(entry, { name: '自动选择', type: 'select', proxies: ['备用组'] });
|
|
349
|
+
|
|
350
|
+
const cases = [
|
|
351
|
+
[{ type: 'fallback', use: ['airport'] }, /仅支持 select/],
|
|
352
|
+
[{ type: 'select' }, /至少选择一个/],
|
|
353
|
+
[{ type: 'select', proxies: ['自动选择'] }, /不能引用自己/],
|
|
354
|
+
[{ type: 'url-test', use: ['airport'], url: 'ftp://x' }, /http\(s\)/],
|
|
355
|
+
];
|
|
356
|
+
for (const [input, re] of cases) {
|
|
357
|
+
await assert.rejects(() => groups.updateGroup('自动选择', input), re);
|
|
358
|
+
assert.deepEqual(
|
|
359
|
+
parseConfig()['proxy-groups'].find((g) => g.name === '自动选择'),
|
|
360
|
+
{ name: '自动选择', type: 'select', proxies: ['备用组'] },
|
|
361
|
+
`配置被意外改动: ${JSON.stringify(input)}`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 不改名:用另一个名字编辑应报「未找到」
|
|
366
|
+
await assert.rejects(() => groups.updateGroup('改了名', { type: 'select', use: ['airport'] }));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('编辑策略组:内核热重载失败时报错,config.yaml 停在新内容', async () => {
|
|
370
|
+
kernelDown = true;
|
|
371
|
+
await assert.rejects(
|
|
372
|
+
() => groups.updateGroup('自动选择', { type: 'select', proxies: ['备用组'] }),
|
|
373
|
+
/热重载失败/,
|
|
374
|
+
);
|
|
375
|
+
kernelDown = false;
|
|
376
|
+
assert.notEqual(readConfig(), BASE_CONFIG);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('落盘当前节点:写入 default-selected(保留注释、其他段落不变、不请求内核)', async () => {
|
|
380
|
+
const beforeDoc = parseConfig();
|
|
381
|
+
const r = await groups.setDefaultSelected({ 节点选择: '日本 中继-1(1.5x)' });
|
|
382
|
+
|
|
383
|
+
assert.deepEqual(r.applied, ['节点选择']);
|
|
384
|
+
assert.deepEqual(r.cleared, []);
|
|
385
|
+
assert.equal(
|
|
386
|
+
kernelCalls,
|
|
387
|
+
0,
|
|
388
|
+
'写 default-selected 不应触发内核请求(运行时已由 PUT /proxies 生效)',
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const text = readConfig();
|
|
392
|
+
assert.match(text, /# Document: https:\/\/wiki\.metacubex\.one/);
|
|
393
|
+
assert.match(text, /# 组注释/);
|
|
394
|
+
assert.match(text, /# 规则注释/);
|
|
395
|
+
// 节点名含空格与括号:写入时加双引号,且落在该组块内(缩进 4)
|
|
396
|
+
assert.match(
|
|
397
|
+
text,
|
|
398
|
+
/\n {2}- name: 节点选择\n(?:.*\n)*? {4}default-selected: "日本 中继-1\(1\.5x\)"\n/,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const doc = parseConfig();
|
|
402
|
+
assert.equal(doc['proxy-groups'][0]['default-selected'], '日本 中继-1(1.5x)');
|
|
403
|
+
assert.deepEqual(doc['proxy-groups'].slice(1), beforeDoc['proxy-groups'].slice(1));
|
|
404
|
+
assert.deepEqual(doc.rules, beforeDoc.rules);
|
|
405
|
+
assert.deepEqual(doc['proxy-providers'], beforeDoc['proxy-providers']);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('落盘当前节点:覆盖已有值 + 多组一次写入只写盘一次', async () => {
|
|
409
|
+
await groups.setDefaultSelected({ 节点选择: '香港 01' });
|
|
410
|
+
assert.equal(parseConfig()['proxy-groups'][0]['default-selected'], '香港 01');
|
|
411
|
+
|
|
412
|
+
const r = await groups.setDefaultSelected({ 节点选择: '台湾 02', 备用组: '自动选择' });
|
|
413
|
+
assert.deepEqual(r.applied.sort(), ['备用组', '节点选择'].sort());
|
|
414
|
+
|
|
415
|
+
const list = parseConfig()['proxy-groups'];
|
|
416
|
+
assert.equal(list.find((g) => g.name === '节点选择')['default-selected'], '台湾 02');
|
|
417
|
+
assert.equal(list.find((g) => g.name === '备用组')['default-selected'], '自动选择');
|
|
418
|
+
// 覆盖时不应出现重复键
|
|
419
|
+
assert.equal((readConfig().match(/default-selected:/g) || []).length, 2);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('落盘当前节点:传 null 删除该项;无变化时不写盘', async () => {
|
|
423
|
+
await groups.setDefaultSelected({ 节点选择: '香港 01' });
|
|
424
|
+
const r = await groups.setDefaultSelected({ 节点选择: null });
|
|
425
|
+
assert.deepEqual(r.cleared, ['节点选择']);
|
|
426
|
+
assert.equal(parseConfig()['proxy-groups'][0]['default-selected'], undefined);
|
|
427
|
+
assert.doesNotMatch(readConfig(), /default-selected/);
|
|
428
|
+
|
|
429
|
+
// 与当前配置一致 → 不写盘
|
|
430
|
+
const beforeText = readConfig();
|
|
431
|
+
const same = await groups.setDefaultSelected({ 节点选择: null });
|
|
432
|
+
assert.deepEqual(same, { applied: [], cleared: [] });
|
|
433
|
+
assert.equal(readConfig(), beforeText);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('落盘当前节点:非 select 组 / 未知组 / 空输入一律拒绝且配置不动', async () => {
|
|
437
|
+
const beforeText = readConfig();
|
|
438
|
+
await assert.rejects(() => groups.setDefaultSelected({ 自动选择: '香港 01' }), /只有 select 组/);
|
|
439
|
+
await assert.rejects(() => groups.setDefaultSelected({ 不存在的组: '香港 01' }), /未找到策略组/);
|
|
440
|
+
await assert.rejects(() => groups.setDefaultSelected({}), /未指定/);
|
|
441
|
+
await assert.rejects(() => groups.setDefaultSelected({ 节点选择: ' ' }), /不能为空/);
|
|
442
|
+
assert.equal(readConfig(), beforeText);
|
|
443
|
+
assert.equal(kernelCalls, 0);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('缩进 0 的列表写法:新增 / 编辑 / 删除 / 落盘当前节点都保留原缩进', async () => {
|
|
447
|
+
// YAML 允许序列与键同缩进:`proxy-groups:` 换行直接接 `- name: ...`
|
|
448
|
+
resetConfig(`mixed-port: 7890
|
|
449
|
+
proxy-groups:
|
|
450
|
+
- name: 手动
|
|
451
|
+
type: select
|
|
452
|
+
proxies: [DIRECT]
|
|
453
|
+
rules:
|
|
454
|
+
- MATCH,手动
|
|
455
|
+
`);
|
|
456
|
+
|
|
457
|
+
await groups.addGroup({ name: '自动', type: 'url-test', proxies: ['DIRECT'] });
|
|
458
|
+
let text = readConfig();
|
|
459
|
+
assert.match(text, /^- name: 自动$/m, '新增条目要跟着用缩进 0');
|
|
460
|
+
assert.doesNotMatch(text, /^ {2}- name: 自动$/m);
|
|
461
|
+
|
|
462
|
+
await groups.updateGroup('手动', { type: 'select', proxies: ['DIRECT', '自动'] });
|
|
463
|
+
await groups.setDefaultSelected({ 手动: '自动' });
|
|
464
|
+
text = readConfig();
|
|
465
|
+
assert.match(
|
|
466
|
+
text,
|
|
467
|
+
/^- name: 手动\n {2}type: select\n {2}proxies: \[DIRECT, 自动\]\n {2}default-selected: "自动"$/m,
|
|
468
|
+
'替换条目与 default-selected 都要用 0 + 2 的缩进',
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
await groups.updateGroup('手动', { type: 'select', proxies: ['DIRECT'] }); // 先解除引用
|
|
472
|
+
await groups.removeGroup('自动');
|
|
473
|
+
assert.doesNotMatch(readConfig(), /name: 自动/);
|
|
474
|
+
assert.deepEqual(
|
|
475
|
+
parseConfig()['proxy-groups'].map((g) => g.name),
|
|
476
|
+
['手动'],
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('编辑策略组:面板没建模的字段(icon / disable-udp / hidden / lazy / expected-status)原样保留', async () => {
|
|
481
|
+
resetConfig(`mixed-port: 7890
|
|
482
|
+
proxy-groups:
|
|
483
|
+
- name: 手动
|
|
484
|
+
type: select
|
|
485
|
+
proxies: [DIRECT]
|
|
486
|
+
icon: https://example.com/i.png
|
|
487
|
+
disable-udp: true
|
|
488
|
+
hidden: true
|
|
489
|
+
- name: 自动
|
|
490
|
+
type: url-test
|
|
491
|
+
use: [airport]
|
|
492
|
+
lazy: true
|
|
493
|
+
expected-status: 204
|
|
494
|
+
rules:
|
|
495
|
+
- MATCH,手动
|
|
496
|
+
`);
|
|
497
|
+
|
|
498
|
+
await groups.updateGroup('手动', { type: 'select', proxies: ['DIRECT'] });
|
|
499
|
+
await groups.updateGroup('自动', {
|
|
500
|
+
type: 'url-test',
|
|
501
|
+
use: ['airport'],
|
|
502
|
+
interval: 120,
|
|
503
|
+
tolerance: 30,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const list = parseConfig()['proxy-groups'];
|
|
507
|
+
const manual = list.find((g) => g.name === '手动');
|
|
508
|
+
assert.equal(manual.icon, 'https://example.com/i.png');
|
|
509
|
+
assert.equal(manual['disable-udp'], true);
|
|
510
|
+
assert.equal(manual.hidden, true);
|
|
511
|
+
|
|
512
|
+
const auto = list.find((g) => g.name === '自动');
|
|
513
|
+
assert.equal(auto.lazy, true);
|
|
514
|
+
assert.equal(auto['expected-status'], 204);
|
|
515
|
+
assert.equal(auto.interval, 120, '面板管理的字段照旧按表单写');
|
|
516
|
+
assert.equal(auto.tolerance, 30);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('编辑策略组:fallback / load-balance 这类没有表单的类型给出明确提示且不改配置', async () => {
|
|
520
|
+
resetConfig(`mixed-port: 7890
|
|
521
|
+
proxy-groups:
|
|
522
|
+
- name: 故障转移
|
|
523
|
+
type: fallback
|
|
524
|
+
proxies: [DIRECT]
|
|
525
|
+
rules:
|
|
526
|
+
- MATCH,故障转移
|
|
527
|
+
`);
|
|
528
|
+
|
|
529
|
+
const beforeText = readConfig();
|
|
530
|
+
await assert.rejects(
|
|
531
|
+
() => groups.updateGroup('故障转移', { type: 'select', proxies: ['DIRECT'] }),
|
|
532
|
+
/只提供手动选择 \/ 自动选择的编辑表单/,
|
|
533
|
+
);
|
|
534
|
+
assert.equal(readConfig(), beforeText);
|
|
535
|
+
assert.equal(kernelCalls, 0);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('内核没在跑(连不上):写盘照旧成功,返回 reloaded:false + warning 而不是报错', async () => {
|
|
539
|
+
// 指向一个没人监听的端口:fetch 直接失败(没有 HTTP 状态码),与「内核回了 500」不是一回事
|
|
540
|
+
fs.writeFileSync(
|
|
541
|
+
configPath(),
|
|
542
|
+
`mixed-port: 7890
|
|
543
|
+
external-controller: 127.0.0.1:1
|
|
544
|
+
proxy-groups:
|
|
545
|
+
- name: 手动
|
|
546
|
+
type: select
|
|
547
|
+
proxies: [DIRECT]
|
|
548
|
+
rules:
|
|
549
|
+
- MATCH,手动
|
|
550
|
+
`,
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const r = await groups.addGroup({ name: '新组', type: 'select', proxies: ['DIRECT'] });
|
|
554
|
+
assert.equal(r.ok, true);
|
|
555
|
+
assert.equal(r.reloaded, false);
|
|
556
|
+
assert.match(r.warning, /内核没在运行/);
|
|
557
|
+
assert.match(readConfig(), /- name: 新组/);
|
|
558
|
+
});
|