@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/mihomo-dash.js +4 -0
  4. package/package.json +63 -4
  5. package/server/CHANGELOG.md +51 -0
  6. package/server/package.json +13 -0
  7. package/server/src/cli/index.js +75 -0
  8. package/server/src/cli/service.js +167 -0
  9. package/server/src/cli/status.js +245 -0
  10. package/server/src/http/config-files.js +148 -0
  11. package/server/src/http/config-order.js +186 -0
  12. package/server/src/http/config-sections.js +278 -0
  13. package/server/src/http/default-config.js +69 -0
  14. package/server/src/http/groups.js +386 -0
  15. package/server/src/http/index.js +260 -0
  16. package/server/src/http/kernel-admin.js +522 -0
  17. package/server/src/http/kernel-proxy.js +43 -0
  18. package/server/src/http/providers.js +251 -0
  19. package/server/src/http/rules.js +509 -0
  20. package/server/src/http/settings.js +409 -0
  21. package/server/src/http/system-proxy.js +86 -0
  22. package/server/src/index.js +26 -0
  23. package/server/src/kernel/archive.js +181 -0
  24. package/server/src/kernel/binary.js +33 -0
  25. package/server/src/kernel/client.js +138 -0
  26. package/server/src/kernel/install.js +150 -0
  27. package/server/src/kernel/lifecycle.js +339 -0
  28. package/server/src/kernel/run.js +11 -0
  29. package/server/src/shared/args.js +57 -0
  30. package/server/src/shared/atomic-file.js +41 -0
  31. package/server/src/shared/exec.js +44 -0
  32. package/server/src/shared/kernel-config.js +27 -0
  33. package/server/src/shared/log-file.js +92 -0
  34. package/server/src/shared/panel.js +58 -0
  35. package/server/src/shared/paths.js +72 -0
  36. package/server/src/shared/service.js +552 -0
  37. package/server/src/shared/system-proxy.js +375 -0
  38. package/server/src/shared/text.js +11 -0
  39. package/server/tests/cli-service.test.js +153 -0
  40. package/server/tests/config-dir.test.js +133 -0
  41. package/server/tests/config-order.test.js +148 -0
  42. package/server/tests/groups.test.js +558 -0
  43. package/server/tests/kernel-admin.test.js +647 -0
  44. package/server/tests/kernel.test.js +250 -0
  45. package/server/tests/log-file.test.js +110 -0
  46. package/server/tests/providers.test.js +259 -0
  47. package/server/tests/rules.test.js +463 -0
  48. package/server/tests/service.test.js +470 -0
  49. package/server/tests/settings.test.js +456 -0
  50. package/server/tests/status.test.js +269 -0
  51. package/server/tests/system-proxy.test.js +497 -0
  52. package/server/tsconfig.json +17 -0
  53. package/web/dist/assets/index-BpDzYhuI.css +1 -0
  54. package/web/dist/assets/index-WmujD2EM.js +515 -0
  55. package/web/dist/favicon.svg +5 -0
  56. package/web/dist/index.html +14 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * 面板管理的规则(server/src/http/rules.js)集成测试。
3
+ * 用桩 HTTP 服务替代内核(只响应 PUT /configs),验证:只改标记块、保留注释与其余规则、
4
+ * 自定义规则只收 DOMAIN-SUFFIX(其它类型被拒)、可删、内核重载失败时报错、块空时连标记一起清理;
5
+ * 以及「系统规则」那几条的启 / 禁(禁用 = 注释掉该行)与改目标。
6
+ * 运行:npm test
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { after, before, beforeEach, test } from 'node:test';
10
+ import fs from 'node:fs';
11
+ import http from 'node:http';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import yaml from 'js-yaml';
15
+
16
+ const BASE_CONFIG = `# Document: https://wiki.metacubex.one/config/
17
+ mixed-port: 7890
18
+ mode: rule
19
+
20
+ proxy-groups:
21
+ - name: 节点选择
22
+ type: select
23
+ proxies: [自动选择]
24
+ # 组注释
25
+
26
+ rules:
27
+ # >>> mihomo-dash 自定义规则(这块由面板维护,请通过规则页增删排序)
28
+ - DOMAIN-SUFFIX,postman.com,节点选择
29
+ # <<< mihomo-dash 自定义规则
30
+ - GEOSITE,cn,DIRECT
31
+ - MATCH,DIRECT
32
+ `;
33
+
34
+ /** @type {string} */ let tmpDir;
35
+ /** @type {string} */ let cfgDir;
36
+ /** @type {string} */ let cliDir;
37
+ /** @type {import('node:http').Server} */ let server;
38
+ /** @type {boolean} */ let kernelDown = false;
39
+ /** @type {typeof import('../src/http/rules.js')} */ let rules;
40
+
41
+ const configPath = () => path.join(cfgDir, 'config.yaml');
42
+ const readConfig = () => fs.readFileSync(configPath(), 'utf8');
43
+ /** 代码只认 config.yaml 的 external-controller(环境变量覆盖已去掉),桩内核地址必须写进配置 */
44
+ let stubPort = 0;
45
+ const withKernel = (text) =>
46
+ `${text}${text.endsWith('\n') ? '' : '\n'}external-controller: 127.0.0.1:${stubPort}\n`;
47
+ const writeConfig = (text) => fs.writeFileSync(configPath(), withKernel(text));
48
+ const parseConfig = () => yaml.load(readConfig());
49
+ /** 面板管理的规则原文(顺序即文件里的顺序) */
50
+ const managed = () => rules.listCustomRules().rules.map((r) => r.raw);
51
+
52
+ before(async () => {
53
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-rules-'));
54
+ cfgDir = path.join(tmpDir, 'mihomo');
55
+ cliDir = path.join(tmpDir, 'cli');
56
+ fs.mkdirSync(cfgDir, { recursive: true });
57
+
58
+ server = http.createServer((_req, res) => {
59
+ res.writeHead(kernelDown ? 500 : 200, { 'content-type': 'application/json' });
60
+ res.end(kernelDown ? JSON.stringify({ message: 'mock reload failure' }) : '{}');
61
+ });
62
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
63
+
64
+ // 必须在 import 之前注入环境变量:paths.js / kernel.js 在模块加载时读取
65
+ process.env.MIHOMO_DIR = cfgDir;
66
+ process.env.MIHOMO_DASH_DIR = cliDir;
67
+ stubPort = server.address().port;
68
+ rules = await import('../src/http/rules.js');
69
+ });
70
+
71
+ after(() => {
72
+ server?.close();
73
+ fs.rmSync(tmpDir, { recursive: true, force: true });
74
+ });
75
+
76
+ beforeEach(() => {
77
+ kernelDown = false;
78
+ writeConfig(BASE_CONFIG);
79
+ });
80
+
81
+ test('读取:块内条目无论什么类型都纳管,带原文与拆解结果', () => {
82
+ writeConfig(
83
+ BASE_CONFIG.replace(
84
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n',
85
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n - GEOSITE,gfw,节点选择\n - MATCH,DIRECT\n',
86
+ ),
87
+ );
88
+
89
+ const data = rules.listCustomRules();
90
+ assert.equal(data.exists, true);
91
+ assert.deepEqual(data.rules, [
92
+ {
93
+ raw: 'DOMAIN-SUFFIX,postman.com,节点选择',
94
+ type: 'DOMAIN-SUFFIX',
95
+ payload: 'postman.com',
96
+ target: '节点选择',
97
+ },
98
+ { raw: 'GEOSITE,gfw,节点选择', type: 'GEOSITE', payload: 'gfw', target: '节点选择' },
99
+ { raw: 'MATCH,DIRECT', type: 'MATCH', payload: '', target: 'DIRECT' },
100
+ ]);
101
+ // total 是 rules 段条目总数(含块外的 GEOSITE,cn 与 MATCH)
102
+ assert.equal(data.total, 5);
103
+ });
104
+
105
+ test('读取:没有标记块时 exists=false', () => {
106
+ writeConfig('rules:\n - MATCH,DIRECT\n');
107
+ const data = rules.listCustomRules();
108
+ assert.equal(data.exists, false);
109
+ assert.deepEqual(data.rules, []);
110
+ assert.equal(data.total, 1);
111
+ });
112
+
113
+ test('新增:默认插到第一条 MATCH 之前(写它后面不会生效),保留注释与其它段落', async () => {
114
+ const r = await rules.addRule({
115
+ type: 'DOMAIN-SUFFIX',
116
+ payload: 'Example.COM',
117
+ target: '节点选择',
118
+ });
119
+
120
+ assert.equal(r.rule, 'DOMAIN-SUFFIX,example.com,节点选择'); // 域名统一小写
121
+ assert.equal(r.insertedBefore, null, '块内没有 MATCH 时追加到末尾');
122
+ assert.deepEqual(parseConfig().rules, [
123
+ 'DOMAIN-SUFFIX,postman.com,节点选择',
124
+ 'DOMAIN-SUFFIX,example.com,节点选择',
125
+ 'GEOSITE,cn,DIRECT',
126
+ 'MATCH,DIRECT',
127
+ ]);
128
+
129
+ // 新行落在两行标记之间;注释与其它段落原样保留
130
+ const text = readConfig();
131
+ const lines = text.split('\n');
132
+ const added = lines.indexOf(' - DOMAIN-SUFFIX,example.com,节点选择');
133
+ assert.ok(added > lines.findIndex((l) => l.includes('>>> mihomo-dash')));
134
+ assert.ok(added < lines.indexOf(' # <<< mihomo-dash 自定义规则'));
135
+ assert.match(text, /# Document: https:\/\/wiki\.metacubex\.one/);
136
+ assert.match(text, /# 组注释/);
137
+ assert.deepEqual(parseConfig()['proxy-groups'], yaml.load(BASE_CONFIG)['proxy-groups']);
138
+ });
139
+
140
+ test('新增:块内已经有 MATCH 时插到它之前', async () => {
141
+ writeConfig(
142
+ BASE_CONFIG.replace(
143
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n',
144
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n - MATCH,DIRECT\n',
145
+ ),
146
+ );
147
+
148
+ const r = await rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'github.com', target: 'DIRECT' });
149
+ assert.equal(r.insertedBefore, 'MATCH,DIRECT');
150
+ assert.deepEqual(managed(), [
151
+ 'DOMAIN-SUFFIX,postman.com,节点选择',
152
+ 'DOMAIN-SUFFIX,github.com,DIRECT',
153
+ 'MATCH,DIRECT',
154
+ ]);
155
+ });
156
+
157
+ test('新增:只接受 DOMAIN-SUFFIX,其它类型一律拒绝且不改文件', async () => {
158
+ // 先把底里那条 MATCH 去掉,避免兜底规则干扰
159
+ writeConfig(
160
+ 'rules:\n # >>> mihomo-dash 自定义规则\n - DOMAIN-SUFFIX,postman.com,节点选择\n # <<< mihomo-dash 自定义规则\n',
161
+ );
162
+ const before = readConfig();
163
+
164
+ for (const input of [
165
+ { type: 'geosite', payload: 'category-ai-!cn', target: '节点选择' },
166
+ { type: 'GEOIP', payload: 'private', target: 'DIRECT' },
167
+ { type: 'MATCH', target: '节点选择' },
168
+ { rule: 'GEOSITE,cn,DIRECT' },
169
+ { rule: 'IP-CIDR,1.1.1.0/24,DIRECT,no-resolve' },
170
+ ]) {
171
+ await assert.rejects(() => rules.addRule(input), /面板只加 DOMAIN-SUFFIX 规则/);
172
+ }
173
+ assert.equal(readConfig(), before, '被拒的请求不能改文件');
174
+
175
+ // DOMAIN-SUFFIX 照旧:整行原文可以,省略 type 也默认 DOMAIN-SUFFIX
176
+ writeConfig('rules:\n - MATCH,DIRECT\n');
177
+ await rules.addRule({ rule: 'DOMAIN-SUFFIX,raw.com,DIRECT' });
178
+ await rules.addRule({ payload: 'added.com', target: 'REJECT' });
179
+ assert.deepEqual(parseConfig().rules, [
180
+ 'DOMAIN-SUFFIX,raw.com,DIRECT',
181
+ 'DOMAIN-SUFFIX,added.com,REJECT',
182
+ 'MATCH,DIRECT',
183
+ ]);
184
+ assert.deepEqual(managed(), ['DOMAIN-SUFFIX,raw.com,DIRECT', 'DOMAIN-SUFFIX,added.com,REJECT']);
185
+ });
186
+
187
+ test('新增:没有标记块时在 rules 段首创建块', async () => {
188
+ writeConfig(
189
+ BASE_CONFIG.replace(/ {2}# >>>.*\n {2}# <<<.*\n/, '').replace(
190
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n',
191
+ '',
192
+ ),
193
+ );
194
+
195
+ await rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'github.com', target: 'DIRECT' });
196
+
197
+ const lines = readConfig().split('\n');
198
+ const begin = lines.findIndex((l) => l.includes('>>> mihomo-dash'));
199
+ assert.deepEqual(lines.slice(begin, begin + 3), [
200
+ ' # >>> mihomo-dash 自定义规则(这块由面板维护,请通过规则页增删排序)',
201
+ ' - DOMAIN-SUFFIX,github.com,DIRECT',
202
+ ' # <<< mihomo-dash 自定义规则',
203
+ ]);
204
+ assert.deepEqual(parseConfig().rules, [
205
+ 'DOMAIN-SUFFIX,github.com,DIRECT',
206
+ 'GEOSITE,cn,DIRECT',
207
+ 'MATCH,DIRECT',
208
+ ]);
209
+ });
210
+
211
+ test('新增:rules: [] 流式空段也能写入', async () => {
212
+ writeConfig('mode: rule\nrules: []\n');
213
+ await rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'a.com', target: 'REJECT' });
214
+ assert.deepEqual(parseConfig().rules, ['DOMAIN-SUFFIX,a.com,REJECT']);
215
+ assert.match(readConfig(), /^rules:$/m);
216
+ });
217
+
218
+ test('新增校验:重复 / 非法类型 / 非法域名 / 空参数 / 空目标 / 无 rules 段都拒绝且不改文件', async () => {
219
+ const before = readConfig();
220
+ await assert.rejects(
221
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'postman.com', target: 'DIRECT' }),
222
+ /规则已存在/,
223
+ );
224
+ await assert.rejects(
225
+ () => rules.addRule({ type: 'BAD TYPE', payload: 'x', target: 'DIRECT' }),
226
+ /面板只加 DOMAIN-SUFFIX 规则/,
227
+ );
228
+ await assert.rejects(
229
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'not a domain', target: 'DIRECT' }),
230
+ /域名不合法/,
231
+ );
232
+ await assert.rejects(
233
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: '', target: 'DIRECT' }),
234
+ /域名不能为空/,
235
+ );
236
+ assert.throws(() => rules.normalizeDomain(''), /域名不能为空/);
237
+ await assert.rejects(
238
+ () => rules.addRule({ type: 'GEOSITE', payload: 'cn', target: 'DIRECT' }),
239
+ /面板只加 DOMAIN-SUFFIX 规则/,
240
+ );
241
+ await assert.rejects(() => rules.addRule({ rule: 'GEOSITE只有一段' }), /规则格式不对/);
242
+ await assert.rejects(
243
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'a.com', target: '' }),
244
+ /目标不能为空/,
245
+ );
246
+ await assert.rejects(
247
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'a.com', target: 'a,b' }),
248
+ /不能包含/,
249
+ );
250
+ assert.equal(readConfig(), before, '被拒的请求不能改文件');
251
+
252
+ writeConfig('mode: rule\n');
253
+ await assert.rejects(
254
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'a.com', target: 'DIRECT' }),
255
+ /未找到 rules 段/,
256
+ );
257
+ });
258
+
259
+ test('新增:内核热重载失败时报错,config.yaml 停在新内容', async () => {
260
+ kernelDown = true;
261
+ await assert.rejects(
262
+ () => rules.addRule({ type: 'DOMAIN-SUFFIX', payload: 'rollback.com', target: 'DIRECT' }),
263
+ /热重载失败/,
264
+ );
265
+ kernelDown = false;
266
+ assert.match(readConfig(), /DOMAIN-SUFFIX,rollback.com,DIRECT/);
267
+ });
268
+
269
+ test('删除:按原文移除块内条目,保留其余规则与注释', async () => {
270
+ const r = await rules.removeRule({ raw: 'DOMAIN-SUFFIX,postman.com,节点选择' });
271
+
272
+ assert.equal(r.rule, 'DOMAIN-SUFFIX,postman.com,节点选择');
273
+ assert.deepEqual(parseConfig().rules, ['GEOSITE,cn,DIRECT', 'MATCH,DIRECT']);
274
+ const text = readConfig();
275
+ assert.match(text, /# 组注释/);
276
+ // 块内已无条目:起始 / 结束标记一并移除,不留空块
277
+ assert.equal(text.includes('>>> mihomo-dash'), false);
278
+ assert.equal(text.includes('<<< mihomo-dash'), false);
279
+ });
280
+
281
+ test('删除:块内还有其它条目时保留标记与相邻条目(删 GEOSITE 也行)', async () => {
282
+ writeConfig(
283
+ BASE_CONFIG.replace(
284
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n',
285
+ ' - DOMAIN-SUFFIX,postman.com,节点选择\n - GEOSITE,gfw,节点选择\n',
286
+ ),
287
+ );
288
+
289
+ await rules.removeRule({ raw: 'GEOSITE,gfw,节点选择' });
290
+
291
+ const text = readConfig();
292
+ assert.match(text, />>> mihomo-dash/);
293
+ assert.match(text, /<<< mihomo-dash/);
294
+ assert.deepEqual(managed(), ['DOMAIN-SUFFIX,postman.com,节点选择']);
295
+ assert.deepEqual(parseConfig().rules, [
296
+ 'DOMAIN-SUFFIX,postman.com,节点选择',
297
+ 'GEOSITE,cn,DIRECT',
298
+ 'MATCH,DIRECT',
299
+ ]);
300
+ });
301
+
302
+ test('删除:找不到 / 没有块时报错', async () => {
303
+ await assert.rejects(() => rules.removeRule({ raw: 'DOMAIN-SUFFIX,nope.com,DIRECT' }), /未找到/);
304
+ await assert.rejects(() => rules.removeRule({}), /缺少要删除的规则/);
305
+
306
+ writeConfig('rules:\n - MATCH,DIRECT\n');
307
+ await assert.rejects(() => rules.removeRule({ raw: 'MATCH,DIRECT' }), /没有面板管理的规则块/);
308
+ });
309
+
310
+ test('删除:rules 段只剩最后一条时连同段头一起移除', async () => {
311
+ writeConfig(
312
+ 'mode: rule\nrules:\n # >>> mihomo-dash 自定义规则\n - DOMAIN-SUFFIX,only.com,DIRECT\n # <<< mihomo-dash 自定义规则\n',
313
+ );
314
+
315
+ await rules.removeRule({ raw: 'DOMAIN-SUFFIX,only.com,DIRECT' });
316
+
317
+ assert.equal(readConfig().includes('rules'), false);
318
+ assert.equal(parseConfig().mode, 'rule');
319
+ });
320
+
321
+ test('系统规则:读出固定清单在当前配置里的状态(含被注释掉的 = 已禁用)', () => {
322
+ writeConfig(
323
+ [
324
+ 'proxy-groups:',
325
+ ' - name: 节点选择',
326
+ ' type: select',
327
+ ' proxies: [DIRECT]',
328
+ 'rules:',
329
+ ' # >>> mihomo-dash 自定义规则(这块由面板维护,请通过规则页增删排序)',
330
+ ' - DOMAIN-SUFFIX,postman.com,节点选择',
331
+ ' # <<< mihomo-dash 自定义规则',
332
+ ' - GEOSITE,private,DIRECT',
333
+ ' # - GEOSITE,category-ads-all,REJECT',
334
+ ' - GEOSITE,gfw,节点选择',
335
+ ' - MATCH,DIRECT',
336
+ '',
337
+ ].join('\n'),
338
+ );
339
+ const { rules: list } = rules.listSystemRules();
340
+ assert.deepEqual(
341
+ list.map((r) => [r.key, r.enabled, r.target]),
342
+ [
343
+ ['private', true, 'DIRECT'],
344
+ ['ads', false, 'REJECT'], // 被注释掉的那行:内容还能读出来
345
+ ['cn', false, 'DIRECT'], // 配置里没有:cn 本来就是直连,默认目标 DIRECT
346
+ ['gfw', true, '节点选择'],
347
+ ['scholar', false, '节点选择'], // 配置里没有:这三条默认走第一个策略组
348
+ ['ai', false, '节点选择'],
349
+ ['match', true, 'DIRECT'],
350
+ ],
351
+ );
352
+ assert.equal(list.find((r) => r.key === 'match').locked, true);
353
+ });
354
+
355
+ test('系统规则:禁用 = 把那行注释掉(内容留着),启用 = 原样放开', async () => {
356
+ writeConfig('rules:\n - GEOSITE,gfw,节点选择\n - MATCH,DIRECT\n');
357
+ await rules.setSystemRule({ key: 'gfw', enabled: false });
358
+
359
+ const text = readConfig();
360
+ assert.match(text, /^ {2}# - GEOSITE,gfw,节点选择$/m);
361
+ assert.deepEqual(parseConfig().rules, ['MATCH,DIRECT']); // 解析结果里它消失了 = 不再生效
362
+
363
+ await rules.setSystemRule({ key: 'gfw', enabled: true });
364
+ assert.match(readConfig(), /^ {2}- GEOSITE,gfw,节点选择$/m);
365
+ assert.deepEqual(parseConfig().rules, ['GEOSITE,gfw,节点选择', 'MATCH,DIRECT']);
366
+ });
367
+
368
+ test('系统规则:配置里没有那一条时,启用会按固定顺序插到合适位置', async () => {
369
+ writeConfig('rules:\n - GEOSITE,cn,DIRECT\n - MATCH,DIRECT\n');
370
+ await rules.setSystemRule({ key: 'gfw', enabled: true, target: '节点选择' });
371
+ // private → ads → cn → gfw → scholar → ai → MATCH:gfw 排在 cn 之后、MATCH 之前
372
+ assert.deepEqual(parseConfig().rules, [
373
+ 'GEOSITE,cn,DIRECT',
374
+ 'GEOSITE,gfw,节点选择',
375
+ 'MATCH,DIRECT',
376
+ ]);
377
+
378
+ await rules.setSystemRule({ key: 'private', enabled: true, target: 'DIRECT' });
379
+ assert.deepEqual(parseConfig().rules, [
380
+ 'GEOSITE,private,DIRECT',
381
+ 'GEOSITE,cn,DIRECT',
382
+ 'GEOSITE,gfw,节点选择',
383
+ 'MATCH,DIRECT',
384
+ ]);
385
+ // 只改目标,不动启停状态
386
+ await rules.setSystemRule({ key: 'gfw', target: '备用组' });
387
+ assert.deepEqual(parseConfig().rules, [
388
+ 'GEOSITE,private,DIRECT',
389
+ 'GEOSITE,cn,DIRECT',
390
+ 'GEOSITE,gfw,备用组',
391
+ 'MATCH,DIRECT',
392
+ ]);
393
+ });
394
+
395
+ test('系统规则:MATCH 不能禁用;未知 key / 空目标都拒绝且不改文件', async () => {
396
+ writeConfig('rules:\n - GEOSITE,gfw,节点选择\n - MATCH,DIRECT\n');
397
+ const before = readConfig();
398
+ await assert.rejects(
399
+ () => rules.setSystemRule({ key: 'match', enabled: false }),
400
+ /兜底规则,不能禁用/,
401
+ );
402
+ await assert.rejects(() => rules.setSystemRule({ key: 'nope', enabled: true }), /未知的系统规则/);
403
+ await assert.rejects(() => rules.setSystemRule({ key: 'gfw', target: ' ' }), /目标不能为空/);
404
+ assert.equal(readConfig(), before);
405
+ // 禁用一条本来就没启用的:不用写盘
406
+ const r = await rules.setSystemRule({ key: 'cn', enabled: false });
407
+ assert.equal(r.enabled, false);
408
+ assert.equal(readConfig(), before);
409
+ });
410
+
411
+ test('config.yaml 还不存在时:系统规则照样列全(都算未启用),自定义规则为空,不报错', () => {
412
+ fs.rmSync(configPath(), { force: true });
413
+ try {
414
+ const sys = rules.listSystemRules();
415
+ assert.equal(sys.rules.length, 7);
416
+ assert.deepEqual(
417
+ sys.rules.map((r) => r.enabled),
418
+ [false, false, false, false, false, false, false],
419
+ );
420
+ // 目标回落:直连/拒绝那几条用自己的默认值,走代理的三条没组可退 → DIRECT
421
+ assert.deepEqual(
422
+ sys.rules.map((r) => r.target),
423
+ ['DIRECT', 'REJECT', 'DIRECT', 'DIRECT', 'DIRECT', 'DIRECT', 'DIRECT'],
424
+ );
425
+ assert.deepEqual(rules.listCustomRules(), { exists: false, rules: [], total: 0 });
426
+ } finally {
427
+ writeConfig(BASE_CONFIG);
428
+ }
429
+ });
430
+
431
+ test('缩进 0 的列表写法:自定义规则与系统规则的启 / 禁都保住原缩进', async () => {
432
+ writeConfig(`mixed-port: 7890
433
+ proxy-groups:
434
+ - name: 节点选择
435
+ type: select
436
+ proxies: [DIRECT]
437
+ rules:
438
+ - MATCH,节点选择
439
+ `);
440
+
441
+ // 新建标记块 + 新增规则:缩进跟着 rules 段里现有条目(0)
442
+ await rules.addRule({ rule: 'DOMAIN-SUFFIX,a.com,DIRECT' });
443
+ let text = readConfig();
444
+ assert.match(text, /^# >>> mihomo-dash 自定义规则/m);
445
+ assert.match(text, /^- DOMAIN-SUFFIX,a\.com,DIRECT$/m);
446
+ assert.deepEqual(managed(), ['DOMAIN-SUFFIX,a.com,DIRECT']);
447
+
448
+ // 系统规则:启用插到 MATCH 之前(indent 0),禁用写成同缩进的注释
449
+ await rules.setSystemRule({ key: 'gfw', enabled: true, target: '节点选择' });
450
+ text = readConfig();
451
+ assert.match(text, /^- GEOSITE,gfw,节点选择\n- MATCH,节点选择$/m);
452
+ assert.deepEqual(parseConfig().rules, [
453
+ 'DOMAIN-SUFFIX,a.com,DIRECT',
454
+ 'GEOSITE,gfw,节点选择',
455
+ 'MATCH,节点选择',
456
+ ]);
457
+
458
+ await rules.setSystemRule({ key: 'gfw', enabled: false });
459
+ text = readConfig();
460
+ assert.match(text, /^# - GEOSITE,gfw,节点选择$/m, '禁用行也用缩进 0');
461
+ assert.deepEqual(parseConfig().rules, ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,节点选择']);
462
+ assert.equal(rules.listSystemRules().rules.find((r) => r.key === 'gfw').enabled, false);
463
+ });