import { describe, expect, it } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { openDocument } from '../vendor/config-editor/index.ts' import { collectInbounds, collectOutboundNames, fallbackTarget, outboundToProxyField, resolveDestination, } from './inbounds.ts' const fixture = openDocument( readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'), ).data describe('合并两套写法的入站', () => { it('只有旧写法时也能列出来,不会是空表', () => { const list = collectInbounds(fixture) expect(list).toHaveLength(1) expect(list[0]!.port).toBe('7777') expect(list[0]!.type).toBe('mixed') expect(list[0]!.isDefault).toBe(true) expect(list[0]!.legacyKey).toBe('mixed-port') }) it('端口写 0 表示没启用,不该出现在表里', () => { const list = collectInbounds({ 'mixed-port': 7777, 'socks-port': 0, port: 0 }) expect(list.map((i) => i.legacyKey)).toEqual(['mixed-port']) }) it('新旧两套同时存在时都要列出来', () => { const list = collectInbounds({ 'mixed-port': 7777, listeners: [{ name: '容器A', type: 'mixed', port: 7778, proxy: 'AUTO' }], }) expect(list.map((i) => i.name)).toEqual(['mixed-port', '容器A']) expect(list[0]!.isDefault).toBe(true) expect(list[1]!.isDefault).toBe(false) expect(list[1]!.listenerIndex).toBe(0) }) it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => { expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' }) }) }) describe('老式端口实际绑在哪', () => { // 内核源码 listener/listener.go:709-718。界面上一律显示 0.0.0.0 会骗人。 it('allow-lan 没开时只绑本机', () => { expect(collectInbounds({ 'mixed-port': 7777 })[0]!.listen).toBe('127.0.0.1') expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': false })[0]!.listen).toBe('127.0.0.1') }) it('allow-lan 开了但没指定地址时绑所有网卡', () => { expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': true })[0]!.listen).toBe('0.0.0.0') expect( collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '*' })[0]!.listen, ).toBe('0.0.0.0') }) it('指定了地址就用那个地址', () => { expect( collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '192.168.1.5' })[0]! .listen, ).toBe('192.168.1.5') }) }) describe('出站去向的四种形态', () => { const of = (proxy?: string) => { // 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」 // 是两回事,测试要还原真实的文件形状。 const listener = { name: 'x', type: 'mixed', port: 1, ...(proxy === undefined ? {} : { proxy }) } return collectInbounds({ listeners: [listener] })[0]!.outbound } it('没写 proxy 就是跟着规则走', () => { expect(of()).toEqual({ kind: 'rules' }) expect(of('')).toEqual({ kind: 'rules' }) }) it('DIRECT 和 GLOBAL 是内核自带的,单独成一类', () => { expect(of('DIRECT')).toEqual({ kind: 'direct' }) expect(of('GLOBAL')).toEqual({ kind: 'global' }) }) it('其余都是指名道姓的出站', () => { expect(of('AUTO')).toEqual({ kind: 'named', name: 'AUTO' }) }) it('写回时能还原成原来的字段值', () => { expect(outboundToProxyField({ kind: 'rules' })).toBeUndefined() expect(outboundToProxyField({ kind: 'direct' })).toBe('DIRECT') expect(outboundToProxyField({ kind: 'global' })).toBe('GLOBAL') expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO') }) }) describe('能被入站引用的出站名字', () => { it('列出内置出站、策略组和自有线路', () => { const names = collectOutboundNames(fixture) expect(names.groups).toEqual(['PROXY', 'AUTO']) expect(names.builtins).toContain('DIRECT') expect(names.builtins).toContain('GLOBAL') }) it('订阅带来的线路不在其中——它们不在内核的名字表上', () => { const names = collectOutboundNames(fixture) // 样本里唯一的订阅是 subs,它带来的线路无法被按名字引用 expect(names.ownProxies).toEqual([]) expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs') }) }) describe('流量最终去哪', () => { const cfg = { 'mixed-port': 7777, rules: ['IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', 'MATCH,PROXY'], listeners: [ { name: '固定', type: 'mixed', port: 7778, proxy: 'AUTO' }, { name: '直连', type: 'mixed', port: 7779, proxy: 'DIRECT' }, ], } it('走规则的入站要继续查到规则表末尾的 MATCH', () => { // 用户想知道的是流量去哪,不是 proxy 字段写没写 const legacy = collectInbounds(cfg)[0]! expect(legacy.outbound).toEqual({ kind: 'rules' }) expect(resolveDestination(cfg, legacy)).toBe('PROXY') }) it('指定了出站的入站就是那个出站', () => { const list = collectInbounds(cfg) expect(resolveDestination(cfg, list[1]!)).toBe('AUTO') expect(resolveDestination(cfg, list[2]!)).toBe('DIRECT') }) it('MATCH 之后的规则到不了,取第一条', () => { expect(fallbackTarget({ rules: ['MATCH,A', 'MATCH,B'] })).toBe('A') }) it('没有 MATCH 时没有兜底', () => { expect(fallbackTarget({ rules: ['MATCH-NOT-REALLY,X'] })).toBeNull() expect(fallbackTarget({})).toBeNull() }) })