@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,456 @@
1
+ /**
2
+ * 快捷设置持久化(server/src/http/settings.js)集成测试。
3
+ * 用桩 HTTP 服务替代内核(记录 PATCH /configs 请求,可切换为失败),验证:
4
+ * 文本改写保留注释、缺失键插入位置、多键一次写入、非法参数拒绝、内核失败时报错。运行: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
+ log-level: warning
18
+ allow-lan: false
19
+
20
+ disable-keep-alive: false
21
+ geodata-mode: false
22
+ geo-auto-update: false
23
+ geo-update-interval: 24
24
+ geox-url:
25
+ geosite: "https://example.com/geosite.dat" # 站点库(注释要保留)
26
+ geoip: "https://example.com/geoip.dat"
27
+
28
+ proxy-providers:
29
+ airport:
30
+ type: http
31
+ url: "https://example.com/link/aaa"
32
+
33
+ rules:
34
+ - MATCH,节点选择
35
+ `;
36
+
37
+ /** 缺 mode / log-level:应插到头部注释块之后 */
38
+ const SPARSE_CONFIG = `# 顶部注释
39
+ # 第二行注释
40
+
41
+ mixed-port: 7890
42
+ allow-lan: false
43
+ `;
44
+
45
+ /** @type {string} */ let tmpDir;
46
+ /** @type {string} */ let cfgDir;
47
+ /** @type {string} */ let cliDir;
48
+ /** @type {import('node:http').Server} */ let server;
49
+ /** @type {boolean} */ let kernelDown = false;
50
+ /** @type {Array<{ method?: string, url: string, body: string }>} */ let requests;
51
+ /** @type {typeof import('../src/http/settings.js')} */ let settings;
52
+
53
+ const configPath = () => path.join(cfgDir, 'config.yaml');
54
+ const readConfig = () => fs.readFileSync(configPath(), 'utf8');
55
+ /** 代码只认 config.yaml 的 external-controller(环境变量覆盖已去掉),桩内核地址必须写进配置 */
56
+ let stubPort = 0;
57
+ const withKernel = (text) =>
58
+ `${text}${text.endsWith('\n') ? '' : '\n'}external-controller: 127.0.0.1:${stubPort}\n`;
59
+ const writeConfig = (text) => fs.writeFileSync(configPath(), withKernel(text));
60
+ const parseConfig = () => yaml.load(readConfig());
61
+ const requestAt = (i = 0) => JSON.parse(requests[i].body);
62
+ const resetConfig = (text = BASE_CONFIG) => writeConfig(text);
63
+
64
+ before(async () => {
65
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-settings-'));
66
+ cfgDir = path.join(tmpDir, 'mihomo');
67
+ cliDir = path.join(tmpDir, 'cli');
68
+ fs.mkdirSync(cfgDir, { recursive: true });
69
+
70
+ server = http.createServer((req, res) => {
71
+ let body = '';
72
+ req.on('data', (c) => (body += c));
73
+ req.on('end', () => {
74
+ requests.push({ method: req.method, url: req.url || '', body });
75
+ res.writeHead(kernelDown ? 500 : 200, { 'content-type': 'application/json' });
76
+ res.end(kernelDown ? JSON.stringify({ message: 'mock patch failure' }) : '{}');
77
+ });
78
+ });
79
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
80
+
81
+ // 必须在 import 之前注入环境变量:paths.js / kernel.js 在模块加载时读取
82
+ process.env.MIHOMO_DIR = cfgDir;
83
+ process.env.MIHOMO_DASH_DIR = cliDir;
84
+ stubPort = server.address().port;
85
+ settings = await import('../src/http/settings.js');
86
+ });
87
+
88
+ after(() => {
89
+ server?.close();
90
+ fs.rmSync(tmpDir, { recursive: true, force: true });
91
+ });
92
+
93
+ beforeEach(() => {
94
+ kernelDown = false;
95
+ requests = [];
96
+ resetConfig();
97
+ });
98
+
99
+ test('mode:改写值、保留注释与其余段落、PATCH 运行时生效', async () => {
100
+ const r = await settings.setSettings({ mode: 'global' });
101
+
102
+ assert.deepEqual(r.applied, ['mode']);
103
+ assert.equal(r.previous.mode, 'rule');
104
+ assert.equal(r.persisted, true);
105
+
106
+ const text = readConfig();
107
+ assert.match(text, /^# Document: https:\/\/wiki\.metacubex\.one/m);
108
+ assert.match(text, /mode: global # 运行模式(注释要保留)/); // 注释保留、值被替换
109
+
110
+ const doc = parseConfig();
111
+ assert.equal(doc.mode, 'global');
112
+ assert.equal(doc['mixed-port'], 7890);
113
+ assert.equal(doc['allow-lan'], false);
114
+ assert.deepEqual(doc.rules, ['MATCH,节点选择']); // 其余段落原样
115
+ assert.equal(doc['proxy-providers'].airport.url, 'https://example.com/link/aaa');
116
+
117
+ assert.equal(requests.length, 1);
118
+ assert.equal(requests[0].method, 'PATCH');
119
+ assert.equal(requests[0].url, '/configs');
120
+ assert.deepEqual(requestAt(), { mode: 'global' });
121
+ });
122
+
123
+ test('多键一次写入:混合类型(数字 / 布尔 / 字符串),一次写盘一次 PATCH', async () => {
124
+ const r = await settings.setSettings({
125
+ 'mixed-port': 7891,
126
+ 'allow-lan': true,
127
+ 'log-level': 'info',
128
+ });
129
+
130
+ assert.deepEqual(r.applied, ['mixed-port', 'allow-lan', 'log-level']);
131
+ const doc = parseConfig();
132
+ assert.equal(doc['mixed-port'], 7891);
133
+ assert.equal(doc['allow-lan'], true);
134
+ assert.equal(doc['log-level'], 'info');
135
+ assert.equal(doc.mode, 'rule');
136
+ assert.equal(requests.length, 1);
137
+ assert.deepEqual(requestAt(), { 'mixed-port': 7891, 'allow-lan': true, 'log-level': 'info' });
138
+ });
139
+
140
+ test('缺键插入:新键落到固定顺序的位置上,文件头注释留在原地', async () => {
141
+ resetConfig(SPARSE_CONFIG);
142
+ const r = await settings.setSettings({ 'log-level': 'error', mode: 'direct' });
143
+
144
+ assert.deepEqual(r.previous, { 'log-level': null, mode: null });
145
+ // 文件头注释不动;mixed-port(基础运行)→ mode → log-level(都在基础运行组内)按固定顺序排
146
+ assert.equal(
147
+ readConfig(),
148
+ `# 顶部注释
149
+ # 第二行注释
150
+ mixed-port: 7890
151
+ allow-lan: false
152
+ mode: direct
153
+ log-level: error
154
+ external-controller: 127.0.0.1:${stubPort}
155
+ `,
156
+ );
157
+ assert.equal(parseConfig().mode, 'direct');
158
+ assert.equal(parseConfig()['log-level'], 'error');
159
+ });
160
+
161
+ test('大小写与空白容忍:GLOBAL → global;INFO → info', async () => {
162
+ const r = await settings.setSettings({ mode: ' GLOBAL ', 'log-level': ' INFO ' });
163
+ assert.deepEqual(r.applied, ['mode', 'log-level']);
164
+ assert.equal(parseConfig().mode, 'global');
165
+ assert.equal(parseConfig()['log-level'], 'info');
166
+ });
167
+
168
+ test('非法值:全部拒绝且不改动配置、不请求内核', async () => {
169
+ const beforeText = readConfig();
170
+ const bad = [
171
+ { mode: 'proxy' },
172
+ { mode: '' },
173
+ { 'mixed-port': 0 },
174
+ { 'mixed-port': 65536 },
175
+ { 'mixed-port': 'abc' },
176
+ { 'allow-lan': 'yes' },
177
+ { 'log-level': 'trace' },
178
+ { unknown: 1 },
179
+ {},
180
+ { mode: 'global', 'mixed-port': -1 }, // 任一键非法则整体拒绝
181
+ ];
182
+ for (const patch of bad) {
183
+ await assert.rejects(() => settings.setSettings(patch), /非法|不支持的配置项|未指定/);
184
+ }
185
+ assert.equal(readConfig(), beforeText);
186
+ assert.equal(requests.length, 0);
187
+ });
188
+
189
+ test('配置已是目标值:不写盘,仅 PATCH 对齐运行时', async () => {
190
+ const r = await settings.setSettings({ mode: 'rule', 'mixed-port': 7890 });
191
+ assert.deepEqual(r.applied, []);
192
+ assert.equal(r.persisted, false);
193
+ assert.deepEqual(r.previous, { mode: 'rule', 'mixed-port': 7890 });
194
+ assert.equal(readConfig(), withKernel(BASE_CONFIG));
195
+ assert.deepEqual(requestAt(), { mode: 'rule', 'mixed-port': 7890 });
196
+ });
197
+
198
+ test('部分键变化:只改写变化的那一个', async () => {
199
+ const r = await settings.setSettings({ mode: 'rule', 'allow-lan': true });
200
+ assert.deepEqual(r.applied, ['allow-lan']);
201
+ const text = readConfig();
202
+ assert.match(text, /mode: rule # 运行模式(注释要保留)/); // 未变化的行原样
203
+ assert.equal(parseConfig()['allow-lan'], true);
204
+ });
205
+
206
+ test('内核 PATCH 失败:报错,config.yaml 停在新内容', async () => {
207
+ kernelDown = true;
208
+ await assert.rejects(() => settings.setSettings({ mode: 'global' }), /内核返回 500/);
209
+
210
+ assert.equal(parseConfig().mode, 'global'); // 写盘已完成,失败只发生在运行时应用这一步
211
+ });
212
+
213
+ test('原配置缺键:新增行写入后 PATCH 失败也不回退', async () => {
214
+ resetConfig(SPARSE_CONFIG);
215
+ kernelDown = true;
216
+ await assert.rejects(() => settings.setSettings({ mode: 'global' }), /内核返回 500/);
217
+ assert.equal(parseConfig().mode, 'global');
218
+ assert.match(readConfig(), /^mode: global$/m);
219
+ });
220
+
221
+ test('tcp-concurrent:PATCH 运行时生效(内核 schema 里有它,不用整包重载)', async () => {
222
+ const r = await settings.setSettings({ 'tcp-concurrent': true });
223
+
224
+ assert.deepEqual(r.applied, ['tcp-concurrent']);
225
+ assert.equal(r.reloaded, false);
226
+ assert.equal(parseConfig()['tcp-concurrent'], true);
227
+ assert.equal(requests.length, 1);
228
+ assert.equal(requests[0].method, 'PATCH');
229
+ assert.deepEqual(requestAt(), { 'tcp-concurrent': true });
230
+ });
231
+
232
+ test('find-process-mode:三个取值都接受、写盘 + PATCH 即时生效(不用重载、不用重启内核)', async () => {
233
+ for (const mode of ['off', 'strict', 'always']) {
234
+ resetConfig();
235
+ requests.length = 0;
236
+ const r = await settings.setSettings({ 'find-process-mode': mode });
237
+ assert.deepEqual(r.applied, ['find-process-mode']);
238
+ assert.equal(r.reloaded, false, `${mode} 不该走整包热重载`);
239
+ assert.equal(parseConfig()['find-process-mode'], mode);
240
+ assert.deepEqual(requestAt(), { 'find-process-mode': mode });
241
+ }
242
+ // 大小写 / 空白归一化
243
+ resetConfig();
244
+ requests.length = 0;
245
+ await settings.setSettings({ 'find-process-mode': ' ALWAYS ' });
246
+ assert.equal(parseConfig()['find-process-mode'], 'always');
247
+ });
248
+
249
+ test('find-process-mode 非法值:拒绝且不改文件', async () => {
250
+ const before = readConfig();
251
+ await assert.rejects(
252
+ () => settings.setSettings({ 'find-process-mode': 'yes' }),
253
+ /find-process-mode 非法/,
254
+ );
255
+ assert.equal(readConfig(), before);
256
+ assert.equal(requests.length, 0);
257
+ });
258
+
259
+ test('find-process-mode 已在目标值:不重写文件,只 PATCH 对齐一次运行时', async () => {
260
+ writeConfig(`${BASE_CONFIG}find-process-mode: always\n`);
261
+ const before = readConfig();
262
+ const r = await settings.setSettings({ 'find-process-mode': 'always' });
263
+ assert.deepEqual(r.applied, []);
264
+ assert.equal(r.persisted, false);
265
+ assert.equal(readConfig(), before, '文件不该被重写');
266
+ // 与其它标量键一致:没变化也会把运行时对齐一次
267
+ assert.equal(requests.length, 1);
268
+ assert.deepEqual(requestAt(), { 'find-process-mode': 'always' });
269
+ });
270
+
271
+ test('unified-delay:PATCH 会被静默忽略,所以走整包热重载', async () => {
272
+ const r = await settings.setSettings({ 'unified-delay': true });
273
+
274
+ assert.deepEqual(r.applied, ['unified-delay']);
275
+ assert.equal(r.reloaded, true);
276
+ assert.equal(parseConfig()['unified-delay'], true);
277
+ assert.equal(requests.length, 1);
278
+ assert.equal(requests[0].method, 'PUT');
279
+ assert.equal(requests[0].url, '/configs?force=true');
280
+ });
281
+
282
+ test('unified-delay 非法值:拒绝且不改文件', async () => {
283
+ const before = readConfig();
284
+ await assert.rejects(
285
+ () => settings.setSettings({ 'unified-delay': 'yes' }),
286
+ /需为 true \/ false/,
287
+ );
288
+ assert.equal(readConfig(), before);
289
+ });
290
+
291
+ test('GEO 开关与间隔:改写顶层键、保留注释,并整包热重载(PUT /configs?force=true)', async () => {
292
+ const r = await settings.setSettings({
293
+ 'geodata-mode': true,
294
+ 'geo-auto-update': true,
295
+ 'geo-update-interval': 12,
296
+ });
297
+
298
+ assert.deepEqual(r.applied, ['geodata-mode', 'geo-auto-update', 'geo-update-interval']);
299
+ assert.equal(r.reloaded, true);
300
+ assert.equal(r.persisted, true);
301
+
302
+ const doc = parseConfig();
303
+ assert.equal(doc['geodata-mode'], true);
304
+ assert.equal(doc['geo-auto-update'], true);
305
+ assert.equal(doc['geo-update-interval'], 12);
306
+ assert.match(readConfig(), /^# Document: https:\/\/wiki\.metacubex\.one/m);
307
+
308
+ assert.equal(requests.length, 1);
309
+ assert.equal(requests[0].method, 'PUT');
310
+ assert.equal(requests[0].url, '/configs?force=true');
311
+ });
312
+
313
+ test('geox-url:就地改写已有子键(保留注释)、新增缺失子键、原样保留其它段落', async () => {
314
+ const r = await settings.setSettings({
315
+ 'geox-url': {
316
+ geosite: 'https://cdn.example.com/geosite.dat',
317
+ mmdb: 'https://cdn.example.com/geoip.metadb',
318
+ },
319
+ });
320
+
321
+ assert.deepEqual(r.applied, ['geox-url']);
322
+ assert.equal(r.reloaded, true);
323
+
324
+ const text = readConfig();
325
+ assert.match(text, /geosite: "https:\/\/cdn\.example\.com\/geosite\.dat" # 站点库(注释要保留)/);
326
+ assert.equal(
327
+ parseConfig()['geox-url'].geoip,
328
+ 'https://example.com/geoip.dat', // 未提及的子键不动
329
+ );
330
+ const geox = parseConfig()['geox-url'];
331
+ assert.deepEqual(geox, {
332
+ geosite: 'https://cdn.example.com/geosite.dat',
333
+ geoip: 'https://example.com/geoip.dat',
334
+ mmdb: 'https://cdn.example.com/geoip.metadb',
335
+ });
336
+ assert.equal(parseConfig()['mixed-port'], 7890);
337
+ assert.equal(requests[0].url, '/configs?force=true');
338
+ });
339
+
340
+ test('geox-url:空串删掉子键(回退内核默认),删空后连同段落头一起移除', async () => {
341
+ const r = await settings.setSettings({ 'geox-url': { geoip: '' } });
342
+ assert.deepEqual(r.applied, ['geox-url']);
343
+ assert.deepEqual(Object.keys(parseConfig()['geox-url']), ['geosite']);
344
+
345
+ await settings.setSettings({ 'geox-url': { geosite: '' } });
346
+ assert.equal(parseConfig()['geox-url'], undefined);
347
+ assert.doesNotMatch(readConfig(), /geox-url/);
348
+ // 其余段落与注释不受影响
349
+ assert.match(readConfig(), /^# Document: https:\/\/wiki\.metacubex\.one/m);
350
+ assert.equal(parseConfig()['mixed-port'], 7890);
351
+ assert.deepEqual(parseConfig()['proxy-providers'], {
352
+ airport: { type: 'http', url: 'https://example.com/link/aaa' },
353
+ });
354
+ });
355
+
356
+ test('geox-url:段落地不存在时新建块状段落,并落到 GEO 那一组的位置上', async () => {
357
+ resetConfig(SPARSE_CONFIG);
358
+ await settings.setSettings({ 'geox-url': { geoip: 'https://cdn.example.com/geoip.dat' } });
359
+
360
+ assert.equal(
361
+ readConfig(),
362
+ `# 顶部注释
363
+ # 第二行注释
364
+ mixed-port: 7890
365
+ allow-lan: false
366
+ external-controller: 127.0.0.1:${stubPort}
367
+ geox-url:
368
+ geoip: "https://cdn.example.com/geoip.dat"
369
+ `,
370
+ );
371
+ assert.deepEqual(parseConfig()['geox-url'], { geoip: 'https://cdn.example.com/geoip.dat' });
372
+ });
373
+
374
+ test('geox-url:子键名必须是 config.yaml 里的写法(geo-site / geo-ip 会被拒绝)', async () => {
375
+ const beforeText = readConfig();
376
+ await assert.rejects(
377
+ () => settings.setSettings({ 'geox-url': { 'geo-site': 'https://a.com/x' } }),
378
+ /不支持的子键/,
379
+ );
380
+ assert.equal(readConfig(), beforeText);
381
+ assert.equal(requests.length, 0);
382
+ });
383
+
384
+ test('GEO 键已是目标值:不写盘、不请求内核', async () => {
385
+ const r = await settings.setSettings({
386
+ 'geodata-mode': false,
387
+ 'geo-auto-update': false,
388
+ 'geo-update-interval': 24,
389
+ 'geox-url': { geosite: 'https://example.com/geosite.dat' },
390
+ });
391
+ assert.deepEqual(r.applied, []);
392
+ assert.equal(r.persisted, false);
393
+ assert.equal(r.reloaded, false);
394
+ assert.equal(readConfig(), withKernel(BASE_CONFIG));
395
+ assert.equal(requests.length, 0);
396
+ });
397
+
398
+ test('GEO 非法值:全部拒绝且不改动配置', async () => {
399
+ const beforeText = readConfig();
400
+ const bad = [
401
+ { 'geodata-mode': 'yes' },
402
+ { 'geo-auto-update': 1 },
403
+ { 'geo-update-interval': 0 },
404
+ { 'geo-update-interval': 721 },
405
+ { 'geox-url': {} },
406
+ { 'geox-url': { geoip: 'ftp://a.com/x' } },
407
+ { 'geox-url': { unknown: 'https://a.com/x' } },
408
+ { 'geox-url': 'https://a.com/x' },
409
+ ];
410
+ for (const patch of bad) {
411
+ await assert.rejects(() => settings.setSettings(patch), /非法|不支持|未指定/);
412
+ }
413
+ assert.equal(readConfig(), beforeText);
414
+ assert.equal(requests.length, 0);
415
+ });
416
+
417
+ test('GEO 热重载失败:报错,config.yaml 停在新内容', async () => {
418
+ kernelDown = true;
419
+ await assert.rejects(() => settings.setSettings({ 'geodata-mode': true }), /热重载失败/);
420
+ assert.equal(parseConfig()['geodata-mode'], true);
421
+ });
422
+
423
+ test('混合键(标量 + GEO):GEO 键存在时整体走整包热重载', async () => {
424
+ const r = await settings.setSettings({ mode: 'global', 'geodata-mode': true });
425
+ assert.deepEqual(r.applied, ['mode', 'geodata-mode']);
426
+ assert.equal(r.reloaded, true);
427
+ assert.equal(parseConfig().mode, 'global');
428
+ assert.equal(requests.length, 1);
429
+ assert.equal(requests[0].url, '/configs?force=true');
430
+ });
431
+
432
+ test('内核没在跑(连不上):写盘照旧成功,返回 reloaded:false + warning(PATCH 与整包重载两条路都一样)', async () => {
433
+ // 指向没人监听的端口:fetch 直接失败(没有 HTTP 状态码),与「内核回了错」不是一回事。
434
+ // 这里直接改文件绕过了写盘接口,所以手动清一下控制接口缓存(面板里由 saveFile / saveConfigWithReload 负责)。
435
+ fs.writeFileSync(
436
+ configPath(),
437
+ `mixed-port: 7890
438
+ external-controller: 127.0.0.1:1
439
+ mode: rule
440
+ `,
441
+ );
442
+ const { resetControllerCache } = await import('../src/kernel/client.js');
443
+ resetControllerCache();
444
+
445
+ const patched = await settings.setSettings({ mode: 'global' });
446
+ assert.equal(patched.persisted, true);
447
+ assert.equal(patched.reloaded, false);
448
+ assert.match(patched.warning, /内核没在运行/);
449
+ assert.equal(parseConfig().mode, 'global');
450
+
451
+ const reloaded = await settings.setSettings({ 'unified-delay': true });
452
+ assert.equal(reloaded.persisted, true);
453
+ assert.equal(reloaded.reloaded, false);
454
+ assert.match(reloaded.warning, /内核没在运行/);
455
+ assert.equal(parseConfig()['unified-delay'], true);
456
+ });