Files
mihomo-patchbay/shared/inbound-groups.test.ts
T
im0o e9e2eb555d 每路入站配一组专属策略组,换线不再改配置文件
原本换一条线要改 config.yaml、跑校验、等内核重载,几秒起步;而换线恰恰是这个工具
最高频的动作,把它放在最重的路径上方向就错了。现在每一路配一个 select 组加 AUTO、
FALLBACK 两个,入站的 proxy 永久指向 select,换线只是调一次内核接口。这样做的前提是
选中不会丢:内核把它写进 cache 文件,重启后由 patchSelectGroup 恢复,而
profile.store-selected 默认为真。

链式代理靠给来源订阅复制一份声明、挂上 override.dialer-proxy 实现,因为这个字段写在
策略组上内核只会打条日志然后忽略。副本是 type: file 读原订阅已经落盘的缓存,而不是
自己再拉一次:自己拉的话订阅地址一不可达副本就是空的,那一路入站会一条线都不剩,
表现是端口还在监听却没有任何出口。

顺带把 shared/outbound-chain.ts 里「只有 Selector 接受手动选择」那条断言删了,它是
错的(内核检查的是 SelectAble 接口,URLTest 和 Fallback 也实现了)。界面仍然只让
Selector 可点,但理由换成了另外两种的手动指定会被它们自己撤销。

完整取舍见工作区决策记录 0007。
2026-08-11 03:10:38 +08:00

322 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'bun:test'
import {
applySetup,
chainGroupName,
chainProviderName,
panelGroups,
syncGenerated,
isGenerated,
linesMatching,
proxyGroupName,
pruneGenerated,
readSetup,
} from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts'
import { collectInbounds } from './inbounds.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
const URL = 'http://127.0.0.1:3001/token/download/V2House'
/** 一份最小但完整的配置:一个订阅、一路入站,还没被 Patchbay 接管过。 */
function base(): ConfigObject {
return {
'proxy-providers': {
subs: { type: 'http', url: URL, path: './providers/subs.yaml', interval: 86400 },
},
listeners: [{ name: 'test1', type: 'mixed', port: 7778 }],
}
}
const plain: OutboundSetup = { proxy: { sources: ['subs'], filter: null }, chain: null }
const chained: OutboundSetup = {
proxy: { sources: ['subs'], filter: null },
chain: { sources: ['subs'], filter: '(?i)DMIT' },
}
describe('名字', () => {
it('三种生成物都带前缀', () => {
// 前缀是识别它们的唯一可靠标记,注释会被配置改写引擎丢掉(决策记录 0002)
expect(isGenerated(proxyGroupName('test1'))).toBe(true)
expect(isGenerated(chainGroupName('test1'))).toBe(true)
expect(isGenerated(chainProviderName('test1', 'subs'))).toBe(true)
})
it('用户自己建的组不会被认成生成的', () => {
expect(isGenerated('PROXY')).toBe(false)
})
it('不同的「入站 × 来源」组合不撞名', () => {
// proxy-providers 是个映射,撞名会让其中一份被静默丢掉,
// 表现是链式代理挂到了别的入站上
const names = new Set([
chainProviderName('a', 'b c'),
chainProviderName('a b', 'c'),
chainProviderName('a', 'b'),
])
expect(names.size).toBe(3)
})
})
describe('筛选预览', () => {
const lines = ['[vmess]DMIT.LAX.Pro.MTYQ', '[vless]Gomami.SIN Secret', '香港01', '香港011']
it('没写筛选条件就是全要', () => {
expect(linesMatching(null, lines)).toEqual(lines)
})
it('部分匹配,不自动加锚点', () => {
// 内核的筛选就是部分匹配,界面预览必须跟它一致,否则预览的数字骗人
expect(linesMatching('香港01', lines)).toEqual(['香港01', '香港011'])
})
it('反引号切成多条正则,取并集', () => {
expect(linesMatching('DMIT`Gomami', lines)).toEqual([
'[vmess]DMIT.LAX.Pro.MTYQ',
'[vless]Gomami.SIN Secret',
])
})
it('写法有误时返回空,让界面提示而不是显示一个错的数字', () => {
expect(linesMatching('[未闭合', lines)).toBeNull()
})
})
describe('一路入站的三个组', () => {
it('SELECT 的成员是 DIRECT 加另外两个组,加上订阅', () => {
// 这三种挑法是所有 clash 配置里固定的那几种,让用户从零拼一遍没有意义
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
expect(groups[0]).toEqual({
name: 'patchbay:test1',
type: 'select',
proxies: ['DIRECT', 'patchbay:test1 AUTO', 'patchbay:test1 FALLBACK'],
use: ['subs'],
})
})
it('AUTO 是 url-test,FALLBACK 是 fallback,都带测速地址', () => {
// 不写测速地址时内核会去借来源订阅的健康检查配置,那依赖订阅写了它,不能指望
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
expect(groups[1]).toEqual({
name: 'patchbay:test1 AUTO',
type: 'url-test',
use: ['subs'],
url: 'https://www.gstatic.com/generate_204',
interval: 300,
})
expect((groups[2] as ConfigObject).type).toBe('fallback')
})
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
const data = applySetup(base(), 'test1', plain)
const runtime = {
'patchbay:test1': { type: 'Selector' },
'patchbay:test1 AUTO': { type: 'URLTest' },
'patchbay:test1 FALLBACK': { type: 'Fallback' },
}
const inbound = collectInbounds(data)[0]!
const panel = panelGroups(data, runtime, inbound)
expect(panel.shared).toBe(false)
expect(panel.groups.map((g) => g.label)).toEqual(['SELECT', 'AUTO', 'FALLBACK'])
expect(panel.groups.map((g) => g.pickable)).toEqual([true, false, false])
expect(panel.groups[0]!.uses).toEqual(['subs'])
})
it('内核里还没有的组不出现在 tab 上', () => {
// 保存完到内核重载完之间会有这么一小段,画一个点不出东西的 tab 只会让人以为坏了
const data = applySetup(base(), 'test1', plain)
const inbound = collectInbounds(data)[0]!
expect(panelGroups(data, {}, inbound).groups).toEqual([])
})
it('没接管的入站落到它顺着规则表走到的那个手动选择组,并标为共用', () => {
const data: ConfigObject = {
...base(),
rules: ['MATCH,PROXY'],
'proxy-groups': [{ name: 'PROXY', type: 'select', use: ['subs'] }],
}
const runtime = { PROXY: { type: 'Selector', now: 'A', all: ['A'] } }
const panel = panelGroups(data, runtime, collectInbounds(data)[0]!)
expect(panel.shared).toBe(true)
expect(panel.groups).toEqual([
{ name: 'PROXY', label: 'PROXY', uses: ['subs'], pickable: true },
])
})
it('筛选条件三个组共用一份', () => {
const groups = applySetup(base(), 'test1', {
proxy: { sources: ['subs'], filter: '(?i)hk' },
chain: null,
})['proxy-groups'] as ConfigObject[]
for (const g of groups) expect((g as ConfigObject).filter).toBe('(?i)hk')
})
})
describe('重建成当前形状', () => {
it('旧形状的生成组会被补齐成三个', () => {
// 改过生成规则之后,早先建的那些停在旧形状上,界面按新形状去找就静默少一块
const stale: ConfigObject = {
...base(),
'proxy-groups': [{ name: 'patchbay:test1', type: 'select', use: ['subs'] }],
listeners: [{ name: 'test1', type: 'mixed', port: 7778, proxy: 'patchbay:test1' }],
}
const fixed = syncGenerated(stale, collectInbounds(stale))
const names = (fixed['proxy-groups'] as ConfigObject[]).map((g) => (g as ConfigObject).name)
expect(names).toEqual(['patchbay:test1', 'patchbay:test1 AUTO', 'patchbay:test1 FALLBACK'])
})
it('没接管的入站不碰', () => {
const data = base()
expect(syncGenerated(data, collectInbounds(data))).toBe(data)
})
})
describe('读取设置', () => {
it('没有代理组时返回空,表示这一路由别的方式决定去向', () => {
expect(readSetup(base(), 'test1')).toBeNull()
})
it('没开链式代理时,来源就是代理组的 use', () => {
const setup: OutboundSetup = { proxy: { sources: ['subs'], filter: '(?i)hk' }, chain: null }
expect(readSetup(applySetup(base(), 'test1', setup), 'test1')).toEqual(setup)
})
it('开了链式代理时,来源从副本的 url 还原', () => {
// 代理组的 use 这时指向副本,用户选的订阅只能靠 url 相同认回来
const data = applySetup(base(), 'test1', chained)
expect(readSetup(data, 'test1')).toEqual(chained)
})
})
describe('写回设置', () => {
it('开链式代理时建入口组,三个组一起改用副本', () => {
const groups = applySetup(base(), 'test1', chained)['proxy-groups'] as ConfigObject[]
const copyName = chainProviderName('test1', 'subs')
// 只有副本挂着 dialer-proxy,自动测速和故障转移也得走副本,否则那两条路绕过了中转
for (const g of groups.slice(0, 3)) expect((g as ConfigObject).use).toEqual([copyName])
expect(groups[3]).toEqual({
name: 'patchbay:test1 链式',
type: 'select',
use: ['subs'],
filter: '(?i)DMIT',
})
})
it('副本读原订阅的缓存文件,自己不联网', () => {
// 副本要是自己去拉,订阅地址一不可达它就是空的,那一路入站的线路会全部消失,
// 表现是端口还在监听却一条线都没有
const data = applySetup(base(), 'test1', chained)
const copy = (data['proxy-providers'] as ConfigObject)[
chainProviderName('test1', 'subs')
] as ConfigObject
expect(copy.type).toBe('file')
expect(copy.path).toBe('./providers/subs.yaml')
expect(copy.url).toBeUndefined()
expect(copy.interval).toBeUndefined()
expect(copy.override).toEqual({ 'dialer-proxy': 'patchbay:test1 链式' })
})
it('原订阅没写 path 时给它补一个,否则副本认不到该读哪个文件', () => {
// 内核会按 url 哈希自己生成缓存路径,那个路径 Patchbay 算不出来
const data = base()
delete ((data['proxy-providers'] as ConfigObject).subs as ConfigObject).path
const next = applySetup(data, 'test1', chained)
const providers = next['proxy-providers'] as ConfigObject
expect((providers.subs as ConfigObject).path).toBe('./providers/subs.yaml')
expect(readSetup(next, 'test1')).toEqual(chained)
})
it('源订阅原有的 override 保留,不被覆盖', () => {
const data = base()
;((data['proxy-providers'] as ConfigObject).subs as ConfigObject).override = { udp: true }
const next = applySetup(data, 'test1', chained)
const copy = (next['proxy-providers'] as ConfigObject)[
chainProviderName('test1', 'subs')
] as ConfigObject
expect(copy.override).toEqual({ udp: true, 'dialer-proxy': 'patchbay:test1 链式' })
})
it('关掉链式代理时,入口组和副本一起消失', () => {
const off = applySetup(applySetup(base(), 'test1', chained), 'test1', plain)
const names = (off['proxy-groups'] as ConfigObject[]).map((g) => (g as ConfigObject).name)
expect(names).not.toContain('patchbay:test1 链式')
expect(Object.keys(off['proxy-providers'] as ConfigObject)).toEqual(['subs'])
})
it('换来源时旧副本立刻消失', () => {
// 留着的话就是一份没人引用、却还在按 interval 定时联网的订阅
const data = base()
const providers = data['proxy-providers'] as ConfigObject
providers.other = { type: 'http', url: 'http://other.example/x', path: './providers/o.yaml' }
const first = applySetup(data, 'test1', chained)
const second = applySetup(first, 'test1', {
proxy: { sources: ['other'], filter: null },
chain: { sources: ['subs'], filter: null },
})
const names = Object.keys(second['proxy-providers'] as ConfigObject)
expect(names).toContain(chainProviderName('test1', 'other'))
expect(names).not.toContain(chainProviderName('test1', 'subs'))
})
it('两路入站各自的副本互不干扰', () => {
const data = base()
;(data.listeners as ConfigObject[]).push({ name: 'test2', type: 'mixed', port: 7779 })
const next = applySetup(applySetup(data, 'test1', chained), 'test2', chained)
const providers = next['proxy-providers'] as ConfigObject
expect(Object.keys(providers)).toHaveLength(3)
expect(readSetup(next, 'test1')).toEqual(chained)
expect(readSetup(next, 'test2')).toEqual(chained)
})
})
describe('清理', () => {
it('删掉入站之后,几个组和副本连锁消失', () => {
// 链式引用:SELECT 引 AUTO 和 FALLBACK,三者引副本,副本引入口组,要反复扫才清得干净
const withInbound = applySetup(base(), 'test1', chained)
const removed: ConfigObject = { ...withInbound, listeners: [] }
const cleaned = pruneGenerated(removed)
expect(cleaned['proxy-groups']).toBeUndefined()
expect(Object.keys(cleaned['proxy-providers'] as ConfigObject)).toEqual(['subs'])
})
it('入站还在就一个都不删', () => {
const data = applySetup(base(), 'test1', chained)
expect(pruneGenerated(data)).toBe(data)
})
it('用户自己建的组和订阅一律不碰,哪怕真的没人引用', () => {
const data: ConfigObject = {
'proxy-groups': [{ name: '我建的组', type: 'select', use: ['subs'] }],
'proxy-providers': { subs: { type: 'http', url: URL } },
}
expect(pruneGenerated(data)).toBe(data)
})
it('没有可删的时候原样返回,不制造无谓的改动', () => {
// 返回同一个对象,落盘链路才看得出「内容没变」而跳过备份和重载
const data: ConfigObject = { 'proxy-groups': [{ name: 'PROXY' }] }
expect(pruneGenerated(data)).toBe(data)
})
it('被规则引用的生成组不删,连它引用的两个组一起留下', () => {
const data = applySetup(base(), 'test1', plain)
const orphaned: ConfigObject = { ...data, listeners: [], rules: ['MATCH,patchbay:test1'] }
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
})
})