import { describe, it, expect } from 'vitest'; import type { XraySettingsValue } from '@/hooks/useXraySetting'; import { isBalancerLoopbackTag, loopbackTagFor, balancerTagFromLoopback, resolveLoopbackFallback, ensureBalancerLoopback, ensureMissingBalancerLoopbacks, removeBalancerLoopback, removeBalancerLoopbackIfOrphaned, propagateBalancerTagRename, detectBalancerCycles, cleanupOrphanedBalancerLoopbacks, } from '@/pages/xray/balancers/balancer-loopback'; interface OutboundEntry { tag?: string; protocol?: string; settings?: { inboundTag?: string }; } interface RuleEntry { type?: string; inboundTag?: string[]; balancerTag?: string; domain?: string[]; } interface BalancerEntry { tag?: string; selector?: string[]; fallbackTag?: string; } function makeSettings(input: { outbounds?: OutboundEntry[]; rules?: RuleEntry[]; balancers?: BalancerEntry[]; }): XraySettingsValue { return { outbounds: input.outbounds, routing: { rules: input.rules, balancers: input.balancers, }, } as XraySettingsValue; } function outboundTags(settings: XraySettingsValue): string[] { return ((settings.outbounds ?? []) as OutboundEntry[]).map((o) => o.tag ?? ''); } function loopbackOutbounds(settings: XraySettingsValue): OutboundEntry[] { return ((settings.outbounds ?? []) as OutboundEntry[]).filter((o) => o.protocol === 'loopback'); } function ruleEntries(settings: XraySettingsValue): RuleEntry[] { return (settings.routing?.rules ?? []) as RuleEntry[]; } function balancerEntries(settings: XraySettingsValue): BalancerEntry[] { return (settings.routing?.balancers ?? []) as BalancerEntry[]; } describe('loopback tag helpers', () => { const cases: Array<{ tag: string; isLoopback: boolean; roundtrip: string | null }> = [ { tag: '_bl_main', isLoopback: true, roundtrip: 'main' }, { tag: 'main', isLoopback: false, roundtrip: null }, { tag: '_bl_', isLoopback: true, roundtrip: '' }, { tag: 'proxy_bl_', isLoopback: false, roundtrip: null }, ]; it.each(cases)('classifies $tag', ({ tag, isLoopback, roundtrip }) => { expect(isBalancerLoopbackTag(tag)).toBe(isLoopback); expect(balancerTagFromLoopback(tag)).toBe(roundtrip); }); it('builds a loopback tag that round-trips back to the balancer tag', () => { expect(loopbackTagFor('cluster-a')).toBe('_bl_cluster-a'); expect(balancerTagFromLoopback(loopbackTagFor('cluster-a'))).toBe('cluster-a'); }); }); describe('resolveLoopbackFallback', () => { const settings = makeSettings({ rules: [{ type: 'field', inboundTag: ['_bl_bal1'], balancerTag: 'bal1' }], }); const cases: Array<{ name: string; input: string; expected: string }> = [ { name: 'resolves a loopback tag through its routing rule', input: '_bl_bal1', expected: 'bal1', }, { name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' }, { name: 'returns an empty tag unchanged', input: '', expected: '' }, { name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' }, ]; it.each(cases)('$name', ({ input, expected }) => { expect(resolveLoopbackFallback(settings, input)).toBe(expected); }); }); describe('ensureBalancerLoopback dedup', () => { it('creates exactly one loopback outbound and one rule when called repeatedly', () => { const settings = makeSettings({ outbounds: [], rules: [], balancers: [] }); ensureBalancerLoopback(settings, 'bal1'); ensureBalancerLoopback(settings, 'bal1'); const loopbacks = loopbackOutbounds(settings); expect(loopbacks).toHaveLength(1); expect(loopbacks[0]).toEqual({ tag: '_bl_bal1', protocol: 'loopback', settings: { inboundTag: '_bl_bal1' }, }); const matchingRules = ruleEntries(settings).filter( (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_bal1'), ); expect(matchingRules).toHaveLength(1); expect(matchingRules[0].balancerTag).toBe('bal1'); }); it('does not duplicate a loopback shared by multiple balancers', () => { const settings = makeSettings({ outbounds: [], rules: [], balancers: [ { tag: 'A', selector: [], fallbackTag: '_bl_shared' }, { tag: 'B', selector: [], fallbackTag: '_bl_shared' }, ], }); ensureMissingBalancerLoopbacks(settings); expect(loopbackOutbounds(settings)).toHaveLength(1); expect( ruleEntries(settings).filter( (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_shared'), ), ).toHaveLength(1); }); }); describe('ensureBalancerLoopback rule ordering', () => { function loopbackRuleIndex(settings: XraySettingsValue, lbTag: string): number { return ruleEntries(settings).findIndex( (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag), ); } function generalRuleIndex(settings: XraySettingsValue): number { return ruleEntries(settings).findIndex( (r) => !Array.isArray(r.inboundTag) || r.inboundTag.length === 0, ); } it('inserts a new loopback rule ahead of a general (no inboundTag) rule', () => { const settings = makeSettings({ rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }], balancers: [{ tag: 'parent', selector: [] }], }); ensureBalancerLoopback(settings, 'target'); expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings)); }); it('repositions an existing loopback rule that landed after a general rule', () => { const settings = makeSettings({ rules: [ { type: 'field', domain: ['example.com'], balancerTag: 'parent' }, { type: 'field', inboundTag: ['_bl_target'], balancerTag: 'stale' }, ], balancers: [{ tag: 'parent', selector: [] }], }); ensureBalancerLoopback(settings, 'target'); const lbIdx = loopbackRuleIndex(settings, '_bl_target'); expect(lbIdx).toBeLessThan(generalRuleIndex(settings)); expect(ruleEntries(settings)[lbIdx].balancerTag).toBe('target'); }); it('leaves inboundTag-restricted rules in place and slots loopback ahead of general rules only', () => { const settings = makeSettings({ rules: [ { type: 'field', inboundTag: ['api'], balancerTag: 'stats' }, { type: 'field', domain: ['example.com'], balancerTag: 'parent' }, ], balancers: [{ tag: 'parent', selector: [] }], }); ensureBalancerLoopback(settings, 'target'); const entries = ruleEntries(settings); expect(entries[0].inboundTag).toEqual(['api']); const lbIdx = loopbackRuleIndex(settings, '_bl_target'); const generalIdx = generalRuleIndex(settings); expect(lbIdx).toBeLessThan(generalIdx); expect(lbIdx).toBeGreaterThan(0); }); it('ensureMissingBalancerLoopbacks repositions every mis-ordered loopback rule', () => { const settings = makeSettings({ rules: [ { type: 'field', domain: ['example.com'], balancerTag: 'B1' }, { type: 'field', inboundTag: ['_bl_B2'], balancerTag: 'B2' }, ], balancers: [ { tag: 'B1', selector: [], fallbackTag: '_bl_B2' }, { tag: 'B2', selector: [] }, ], }); ensureMissingBalancerLoopbacks(settings); expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(generalRuleIndex(settings)); }); it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => { const settings = makeSettings({ rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }], balancers: [{ tag: 'parent', selector: [] }], }); ensureBalancerLoopback(settings, 'target'); ensureBalancerLoopback(settings, 'target'); expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings)); expect( ruleEntries(settings).filter( (r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'), ), ).toHaveLength(1); }); }); describe('detectBalancerCycles', () => { const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [ { name: 'two-balancer loop A -> B -> A', balancers: [ { tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B', fallbackTag: '_bl_A' }, ], expected: [ ['A', 'B'], ['B', 'A'], ], }, { name: 'self loop A -> A', balancers: [{ tag: 'A', fallbackTag: '_bl_A' }], expected: [['A', 'A']], }, { name: 'three-balancer loop A -> B -> C -> A', balancers: [ { tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B', fallbackTag: '_bl_C' }, { tag: 'C', fallbackTag: '_bl_A' }, ], expected: [ ['A', 'B'], ['B', 'C'], ['C', 'A'], ], }, { name: 'linear chain is not a cycle', balancers: [{ tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B' }], expected: [], }, { name: 'non-loopback fallback is ignored', balancers: [{ tag: 'A', fallbackTag: 'direct' }], expected: [], }, ]; it.each(cases)('$name', ({ balancers, expected }) => { expect(detectBalancerCycles(makeSettings({ balancers }))).toEqual(expected); }); }); describe('propagateBalancerTagRename', () => { it('rewrites the loopback outbound, its rule and referring fallback tags', () => { const settings = makeSettings({ outbounds: [{ tag: '_bl_old', protocol: 'loopback', settings: { inboundTag: '_bl_old' } }], rules: [{ type: 'field', inboundTag: ['_bl_old'], balancerTag: 'old' }], balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_old' }], }); propagateBalancerTagRename(settings, 'old', 'new'); const [outbound] = loopbackOutbounds(settings); expect(outbound.tag).toBe('_bl_new'); expect(outbound.settings?.inboundTag).toBe('_bl_new'); expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_new']); expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_new'); }); it('leaves unrelated loopback tags untouched', () => { const settings = makeSettings({ outbounds: [ { tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } }, ], rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }], balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }], }); propagateBalancerTagRename(settings, 'old', 'new'); expect(loopbackOutbounds(settings)[0].tag).toBe('_bl_other'); expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_other']); expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_other'); }); }); describe('orphan cleanup', () => { it('cleanupOrphanedBalancerLoopbacks removes only unreferenced loopbacks', () => { const settings = makeSettings({ outbounds: [ { tag: '_bl_gone', protocol: 'loopback', settings: { inboundTag: '_bl_gone' } }, { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }, { tag: 'proxy', protocol: 'vless' }, ], rules: [ { type: 'field', inboundTag: ['_bl_gone'], balancerTag: 'gone' }, { type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }, ], balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }], }); cleanupOrphanedBalancerLoopbacks(settings); expect(outboundTags(settings)).toEqual(['_bl_kept', 'proxy']); expect(ruleEntries(settings).map((r) => r.inboundTag)).toEqual([['_bl_kept']]); }); it('removeBalancerLoopbackIfOrphaned keeps a loopback that is still referenced', () => { const settings = makeSettings({ outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }], rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }], balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }], }); removeBalancerLoopbackIfOrphaned(settings, 'kept'); expect(loopbackOutbounds(settings)).toHaveLength(1); expect(ruleEntries(settings)).toHaveLength(1); }); it('removeBalancerLoopbackIfOrphaned drops a loopback with no referrers', () => { const settings = makeSettings({ outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }], rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }], balancers: [], }); removeBalancerLoopbackIfOrphaned(settings, 'kept'); expect(loopbackOutbounds(settings)).toHaveLength(0); expect(ruleEntries(settings)).toHaveLength(0); }); it('removeBalancerLoopback deletes the outbound and rule directly', () => { const settings = makeSettings({ outbounds: [ { tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }, { tag: 'proxy', protocol: 'vless' }, ], rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }], balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }], }); removeBalancerLoopback(settings, 'kept'); expect(outboundTags(settings)).toEqual(['proxy']); expect(ruleEntries(settings)).toHaveLength(0); }); });