Files
mihomo-patchbay/shared/inbound-groups.test.ts
T
im0o 6bedce215c SELECT 组的点名成员 AUTO 排第一,不再静默回退到直连
内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时也
静默回退到第一个成员(selector.go:103-112)。之前第一位是 DIRECT,这两种
情况都会让一路入站在无人察觉时变成直连,对按端口定向出站的场景是泄露。

另加 sharedPeers():「共用」标记只在真有别的入站落在同一个组上时才该出现,
只有自己在用时它是噪音。
2026-08-11 14:39:26 +08:00

359 lines
14 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,
sharedPeers,
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,再加上订阅,AUTO 排第一', () => {
// AUTO 必须排第一:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅删掉时
// 也回退到第一个成员。这两种情况落到 DIRECT 上就是静默直连(selector.go:103-112)
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
expect(groups[0]).toEqual({
name: 'patchbay:test1',
type: 'select',
proxies: ['patchbay:test1 AUTO', 'patchbay:test1 FALLBACK', 'DIRECT'],
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 data: ConfigObject = {
...base(),
'mixed-port': 7890,
rules: ['MATCH,PROXY'],
'proxy-groups': [{ name: 'PROXY', type: 'select', use: ['subs'] }],
}
const runtime = { PROXY: { type: 'Selector', now: 'A', all: ['A'] } }
const inbounds = collectInbounds(data)
const legacy = inbounds.find((i) => i.isDefault)!
expect(sharedPeers(data, runtime, inbounds, legacy)).toEqual(['test1'])
})
it('只有自己落在那个组上时没有共用者', () => {
const data: ConfigObject = {
'mixed-port': 7890,
rules: ['MATCH,PROXY'],
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'] }],
}
const runtime = { PROXY: { type: 'Selector', now: 'DIRECT', all: ['DIRECT'] } }
const inbounds = collectInbounds(data)
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
})
it('接管过的入站用的是自己的组,谈不上共用', () => {
const data = applySetup(base(), 'test1', plain)
const runtime = { 'patchbay:test1': { type: 'Selector' } }
const inbounds = collectInbounds(data)
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
})
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('开了链式代理时,来源从副本的 path 还原', () => {
// 代理组的 use 这时指向副本,用户选的订阅只能靠读同一个缓存文件认回来
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)
})
})