merge feat/singbox-b4: item 6 B0-B4 (sing-box model, generation, agent runtime, frontend reshaping)
This commit is contained in:
@@ -1,385 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,455 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(
|
||||
routing: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {},
|
||||
): XraySettingsValue {
|
||||
return { routing, ...extra } as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
|
||||
|
||||
function expectObserver(
|
||||
t: XraySettingsValue,
|
||||
expected: ExpectedObserver,
|
||||
selectors: string[] = [],
|
||||
) {
|
||||
if (expected === 'none') {
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
if (expected === 'observatory') {
|
||||
expect(t.observatory).toBeDefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(selectors),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(selectors),
|
||||
);
|
||||
}
|
||||
|
||||
// Observatory sections have no reload API in xray-core, so creating one turns
|
||||
// a balancer save from a live (hot-applied) routing change into a full
|
||||
// restart. These tests pin the rule: only strategies that genuinely need an
|
||||
// observer may create one — which, for random/roundRobin, means a fallbackTag
|
||||
// is set (xray-core then requires the Observatory feature; see #5605).
|
||||
describe('syncObservatories', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'random without fallback',
|
||||
balancers: [{ tag: 'random', selector: ['random-out'] }],
|
||||
expected: 'none' as const,
|
||||
selectors: [],
|
||||
},
|
||||
{
|
||||
name: 'random with fallback',
|
||||
balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['random-out'],
|
||||
},
|
||||
{
|
||||
name: 'roundRobin without fallback',
|
||||
balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
|
||||
expected: 'none' as const,
|
||||
selectors: [],
|
||||
},
|
||||
{
|
||||
name: 'roundRobin with fallback',
|
||||
balancers: [
|
||||
{
|
||||
tag: 'rr',
|
||||
selector: ['rr-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['rr-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing without fallback',
|
||||
balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing with fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } },
|
||||
],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad without fallback',
|
||||
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad with fallback',
|
||||
balancers: [
|
||||
{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
|
||||
const t = tpl({ balancers });
|
||||
syncObservatories(t);
|
||||
expectObserver(t, expected, selectors);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'random + roundRobin without fallbacks',
|
||||
balancers: [
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'none' as const,
|
||||
selectors: [],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + random without fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + roundRobin without fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'observatory' as const,
|
||||
selectors: ['lp-out'],
|
||||
},
|
||||
{
|
||||
name: 'random fallback + random without fallback',
|
||||
balancers: [
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['random-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'roundRobin fallback + roundRobin without fallback',
|
||||
balancers: [
|
||||
{
|
||||
tag: 'rrf',
|
||||
selector: ['rr-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['rr-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad + random without fallback',
|
||||
balancers: [
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastLoad + roundRobin without fallback',
|
||||
balancers: [
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['ll-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + leastLoad with disjoint selectors',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + random fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'random-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'leastPing + roundRobin fallback',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{
|
||||
tag: 'rrf',
|
||||
selector: ['rr-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['lp-out', 'rr-fallback-out'],
|
||||
},
|
||||
{
|
||||
name: 'all strategies mixed',
|
||||
balancers: [
|
||||
{ tag: 'random', selector: ['random-out'] },
|
||||
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{
|
||||
tag: 'rrf',
|
||||
selector: ['rr-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
|
||||
},
|
||||
{
|
||||
name: 'shared selectors are de-duplicated',
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
|
||||
],
|
||||
expected: 'burstObservatory' as const,
|
||||
selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
|
||||
},
|
||||
])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
|
||||
const t = tpl({ balancers });
|
||||
syncObservatories(t);
|
||||
expectObserver(t, expected, selectors);
|
||||
});
|
||||
|
||||
it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
expect(t.observatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not create burstObservatory for roundRobin without fallback', () => {
|
||||
const t = tpl({
|
||||
balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => {
|
||||
const t = tpl({
|
||||
balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
|
||||
'opera-proxy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates burstObservatory for roundRobin with a fallbackTag', () => {
|
||||
const t = tpl({
|
||||
balancers: [
|
||||
{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] });
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes burstObservatory when a random balancer drops its fallbackTag', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] },
|
||||
{ burstObservatory: { subjectSelector: ['opera-proxy'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] },
|
||||
{ burstObservatory: { subjectSelector: ['a'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'b1', selector: ['a'] },
|
||||
{ tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
|
||||
],
|
||||
},
|
||||
{ burstObservatory: { subjectSelector: ['a', 'b'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('creates burstObservatory for leastLoad (required by the strategy)', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
expect(t.burstObservatory).toBeDefined();
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('creates observatory for leastPing when no burst observer is required', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeDefined();
|
||||
expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['least-load-out', 'least-ping-out']),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
|
||||
{
|
||||
tag: 'rr',
|
||||
selector: ['round-robin-fallback-out'],
|
||||
fallbackTag: 'direct',
|
||||
strategy: { type: 'roundRobin' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
|
||||
const t = tpl(
|
||||
{
|
||||
balancers: [
|
||||
{ tag: 'b1', selector: ['a'] },
|
||||
{ tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
{ burstObservatory: { subjectSelector: ['stale'] } },
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('removes observatories when no balancer can use them', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [] },
|
||||
{
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
},
|
||||
);
|
||||
syncObservatories(t);
|
||||
expect(t.observatory).toBeUndefined();
|
||||
expect(t.burstObservatory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
|
||||
{
|
||||
observatory: { subjectSelector: ['stale-observatory-out'] },
|
||||
burstObservatory: { subjectSelector: ['stale-burst-out'] },
|
||||
},
|
||||
);
|
||||
syncObservatories(t);
|
||||
expectObserver(t, 'observatory', ['lp-out']);
|
||||
});
|
||||
|
||||
it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
|
||||
const t = tpl(
|
||||
{ balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
|
||||
{
|
||||
observatory: { subjectSelector: ['stale-observatory-out'] },
|
||||
burstObservatory: { subjectSelector: ['stale-burst-out'] },
|
||||
},
|
||||
);
|
||||
syncObservatories(t);
|
||||
expectObserver(t, 'burstObservatory', ['ll-out']);
|
||||
});
|
||||
|
||||
it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
|
||||
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
|
||||
syncObservatories(t);
|
||||
const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
|
||||
expect(burst.pingConfig.httpMethod).toBe('HEAD');
|
||||
expect(burst.pingConfig.sampling).toBe(2);
|
||||
});
|
||||
|
||||
it('drops only the prefixes no remaining balancer uses (note #2)', () => {
|
||||
const t = tpl({
|
||||
balancers: [
|
||||
{ tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
{ tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
});
|
||||
syncObservatories(t);
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['prefixA', 'prefixB', 'prefixC']),
|
||||
);
|
||||
(t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
|
||||
syncObservatories(t);
|
||||
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
|
||||
new Set(['prefixC', 'prefixB']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import BasicsTab from '@/pages/xray/basics/BasicsTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function settingsWithMalformedHappyEyeballs(): XraySettingsValue {
|
||||
return {
|
||||
outbounds: [
|
||||
{
|
||||
protocol: 'freedom',
|
||||
tag: 'direct',
|
||||
streamSettings: { sockopt: { happyEyeballs: { tryDelayMs: 'fast' } } },
|
||||
},
|
||||
],
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('BasicsTab malformed happyEyeballs', () => {
|
||||
it('renders instead of white-screening on a wrong-typed happyEyeballs value', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(() =>
|
||||
renderWithProviders(
|
||||
<BasicsTab
|
||||
templateSettings={settingsWithMalformedHappyEyeballs()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
onResetDefault={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GeoBrowserModal from '@/components/geodata/GeoBrowserModal';
|
||||
import GeoTokenInput from '@/components/geodata/GeoTokenInput';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILES = [
|
||||
{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 },
|
||||
];
|
||||
|
||||
const IP_FILE = {
|
||||
name: 'geoip.dat',
|
||||
kind: 'ip',
|
||||
size: 2048,
|
||||
modifiedAt: 1785428467270,
|
||||
categories: 1,
|
||||
};
|
||||
|
||||
const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
|
||||
|
||||
const CATEGORIES = {
|
||||
total: 3,
|
||||
items: [
|
||||
{ code: 'cn', entries: 2, attributes: [] },
|
||||
{ code: 'google', entries: 2, attributes: ['ads'] },
|
||||
{ code: 'telegram', entries: 1, attributes: [] },
|
||||
],
|
||||
};
|
||||
|
||||
function mockGeodata(files: unknown[] = FILES) {
|
||||
const get = vi
|
||||
.spyOn(HttpUtil, 'get')
|
||||
.mockImplementation(async (url: string, params?: unknown) => {
|
||||
const requestedFile = (params as { file?: string } | undefined)?.file;
|
||||
if (url.includes('/geodata/files')) return new Msg(true, '', files);
|
||||
if (url.includes('/geodata/categories')) {
|
||||
return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
|
||||
}
|
||||
if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
|
||||
return get;
|
||||
}
|
||||
|
||||
type GetSpy = ReturnType<typeof mockGeodata>;
|
||||
|
||||
function entryFilters(get: GetSpy): string[] {
|
||||
return get.mock.calls
|
||||
.filter(([url]) => String(url).includes('/geodata/entries'))
|
||||
.map(([, params]) => (params as { q?: string } | undefined)?.q ?? '');
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
async function checkboxFor(code: string) {
|
||||
const cell = await screen.findByText(code);
|
||||
const row = cell.closest('.ant-table-row');
|
||||
if (!row) throw new Error(`row for ${code} not found`);
|
||||
return within(row as HTMLElement).getByRole('checkbox') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('GeoBrowserModal selection', () => {
|
||||
it('seeds the selection from the field every time it opens', async () => {
|
||||
mockGeodata();
|
||||
const view = render(
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="geosite:google"
|
||||
onApply={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
|
||||
view.rerender(
|
||||
<GeoBrowserModal
|
||||
open={false}
|
||||
kind="site"
|
||||
value="geosite:google"
|
||||
onApply={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
view.rerender(
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="geosite:google"
|
||||
onApply={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
expect((await checkboxFor('cn')).checked).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps selections that the search box has filtered out of view', async () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await user.click(await checkboxFor('google'));
|
||||
await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
|
||||
await waitFor(() => expect(screen.queryByText('google')).toBeNull());
|
||||
await user.click(await checkboxFor('cn'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
const applied = String(onApply.mock.calls[0][0]);
|
||||
expect(
|
||||
applied
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.sort(),
|
||||
).toEqual(['geosite:cn', 'geosite:google']);
|
||||
});
|
||||
|
||||
it('drops a category from the field when its checkbox is cleared', async () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="google.com, geosite:google, geosite:blabla"
|
||||
onApply={onApply}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
await user.click(await checkboxFor('google'));
|
||||
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('google.com, geosite:blabla');
|
||||
});
|
||||
|
||||
it('offers only databases matching the field kind', async () => {
|
||||
mockGeodata([...FILES, IP_FILE]);
|
||||
render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await screen.findByText('private');
|
||||
expect(screen.getByTitle('geoip.dat')).toBeTruthy();
|
||||
expect(screen.queryByText('google')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not seed one database from another database categories', async () => {
|
||||
mockGeodata([...FILES, IP_FILE]);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GeoBrowserModal open kind="site" value="geosite:cn" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
|
||||
await user.click(await checkboxFor('google'));
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
expect(screen.queryByText('private')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the entry filter to settle instead of querying every keystroke', async () => {
|
||||
const get = mockGeodata();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByText('cn'));
|
||||
await waitFor(() => expect(entryFilters(get)).toEqual(['']));
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||
expect(entryFilters(get)).toEqual(['']);
|
||||
|
||||
await waitFor(() => expect(entryFilters(get)).toEqual(['', 'abcd']), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('drops the pending filter when another category is opened', async () => {
|
||||
const get = mockGeodata();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByText('cn'));
|
||||
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||
await user.click(screen.getByText('telegram'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
expect(entryFilters(get)).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('ticks and unticks a category written in its long ext form', async () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="ext:geosite.dat:cn, google.com"
|
||||
onApply={onApply}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
|
||||
await user.click(await checkboxFor('cn'));
|
||||
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('google.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeoTokenInput validation feedback', () => {
|
||||
it('says the check failed instead of dropping the warnings silently', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
vi.spyOn(HttpUtil, 'post')
|
||||
.mockResolvedValueOnce(
|
||||
new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]),
|
||||
)
|
||||
.mockResolvedValue(new Msg(false, 'too many tokens'));
|
||||
|
||||
const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
|
||||
await screen.findByText(/Not in the database/, {}, { timeout: 3000 });
|
||||
|
||||
view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
|
||||
|
||||
await screen.findByText(
|
||||
'Could not check these values against the geo databases',
|
||||
{},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
expect(screen.queryByText(/Not in the database/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatTokens,
|
||||
mergeSelection,
|
||||
parseTokens,
|
||||
selectionFromValue,
|
||||
tokenFor,
|
||||
} from '@/lib/xray/geoTokens';
|
||||
|
||||
const siteKnown = new Set([
|
||||
'geosite:google',
|
||||
'geosite:google@ads',
|
||||
'geosite:cn',
|
||||
'ext:my_rules.dat:corp',
|
||||
]);
|
||||
const ipKnown = new Set(['geoip:cn', 'geoip:private', 'ext:my_ips.dat:office']);
|
||||
|
||||
describe('parseTokens / formatTokens', () => {
|
||||
const cases: Array<[string, string, string[]]> = [
|
||||
['empty value', '', []],
|
||||
['single token', 'geosite:google', ['geosite:google']],
|
||||
[
|
||||
'trims and drops blanks',
|
||||
' geosite:google , , google.com ,',
|
||||
['geosite:google', 'google.com'],
|
||||
],
|
||||
['keeps negation', '!geoip:cn, 10.0.0.0/8', ['!geoip:cn', '10.0.0.0/8']],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, expected) => {
|
||||
expect(parseTokens(value)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('joins with a comma and a space', () => {
|
||||
expect(formatTokens(['geosite:google', 'google.com'])).toBe('geosite:google, google.com');
|
||||
expect(formatTokens([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenFor', () => {
|
||||
const cases: Array<[string, string, string, 'site' | 'ip', string]> = [
|
||||
[
|
||||
'default site database uses the geosite shorthand',
|
||||
'geosite.dat',
|
||||
'google',
|
||||
'site',
|
||||
'geosite:google',
|
||||
],
|
||||
['default ip database uses the geoip shorthand', 'geoip.dat', 'cn', 'ip', 'geoip:cn'],
|
||||
[
|
||||
'custom site database falls back to ext',
|
||||
'my_rules.dat',
|
||||
'corp',
|
||||
'site',
|
||||
'ext:my_rules.dat:corp',
|
||||
],
|
||||
['custom ip database falls back to ext', 'my_ips.dat', 'office', 'ip', 'ext:my_ips.dat:office'],
|
||||
[
|
||||
'ip kind on the site database is not shorthand',
|
||||
'geosite.dat',
|
||||
'cn',
|
||||
'ip',
|
||||
'ext:geosite.dat:cn',
|
||||
],
|
||||
[
|
||||
'site kind on the ip database is not shorthand',
|
||||
'geoip.dat',
|
||||
'cn',
|
||||
'site',
|
||||
'ext:geoip.dat:cn',
|
||||
],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, file, code, kind, expected) => {
|
||||
expect(tokenFor(file, code, kind)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionFromValue', () => {
|
||||
const cases: Array<[string, string, ReadonlySet<string>, string[]]> = [
|
||||
['empty value selects nothing', '', siteKnown, []],
|
||||
['plain values are not selectable', 'google.com, keyword:ads', siteKnown, []],
|
||||
[
|
||||
'picks known tokens only',
|
||||
'google.com, geosite:google, geosite:blabla',
|
||||
siteKnown,
|
||||
['geosite:google'],
|
||||
],
|
||||
[
|
||||
'keeps the value order',
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
siteKnown,
|
||||
['geosite:cn', 'geosite:google'],
|
||||
],
|
||||
['drops duplicates', 'geosite:google, geosite:google', siteKnown, ['geosite:google']],
|
||||
['attributes are distinct tokens', 'geosite:google@ads', siteKnown, ['geosite:google@ads']],
|
||||
[
|
||||
'ext tokens are selectable',
|
||||
'ext:my_rules.dat:corp, ext:other.dat:x',
|
||||
siteKnown,
|
||||
['ext:my_rules.dat:corp'],
|
||||
],
|
||||
['negated ip tokens stay unselected', '!geoip:cn, geoip:private', ipKnown, ['geoip:private']],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, known, expected) => {
|
||||
expect(selectionFromValue(value, known)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSelection', () => {
|
||||
const cases: Array<[string, string, string[], ReadonlySet<string>, string]> = [
|
||||
['adds to an empty field', '', ['geosite:google'], siteKnown, 'geosite:google'],
|
||||
[
|
||||
'adds after a plain domain',
|
||||
'google.com',
|
||||
['geosite:cn'],
|
||||
siteKnown,
|
||||
'google.com, geosite:cn',
|
||||
],
|
||||
[
|
||||
'keeps plain and unknown tokens when a category is unchecked',
|
||||
'google.com, geosite:google, geosite:blabla',
|
||||
[],
|
||||
siteKnown,
|
||||
'google.com, geosite:blabla',
|
||||
],
|
||||
[
|
||||
'unchecking one known token leaves the other known token',
|
||||
'geosite:google, geosite:cn',
|
||||
['geosite:cn'],
|
||||
siteKnown,
|
||||
'geosite:cn',
|
||||
],
|
||||
[
|
||||
'preserves the original order of surviving tokens',
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
['geosite:google', 'geosite:cn'],
|
||||
siteKnown,
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
],
|
||||
[
|
||||
'appends new selections in selection order',
|
||||
'google.com',
|
||||
['geosite:cn', 'geosite:google'],
|
||||
siteKnown,
|
||||
'google.com, geosite:cn, geosite:google',
|
||||
],
|
||||
[
|
||||
'never duplicates an already present token',
|
||||
'geosite:google, google.com',
|
||||
['geosite:google'],
|
||||
siteKnown,
|
||||
'geosite:google, google.com',
|
||||
],
|
||||
[
|
||||
'collapses duplicates already in the field',
|
||||
'google.com, google.com, geosite:google',
|
||||
['geosite:google'],
|
||||
siteKnown,
|
||||
'google.com, geosite:google',
|
||||
],
|
||||
[
|
||||
'handles ext tokens like shorthand ones',
|
||||
'ext:my_rules.dat:corp, google.com',
|
||||
[],
|
||||
siteKnown,
|
||||
'google.com',
|
||||
],
|
||||
[
|
||||
'adds an ext token from a custom database',
|
||||
'10.0.0.0/8',
|
||||
['ext:my_ips.dat:office'],
|
||||
ipKnown,
|
||||
'10.0.0.0/8, ext:my_ips.dat:office',
|
||||
],
|
||||
[
|
||||
'leaves a negated ip token untouched while dropping a plain one',
|
||||
'!geoip:cn, geoip:private, 192.168.0.0/16',
|
||||
[],
|
||||
ipKnown,
|
||||
'!geoip:cn, 192.168.0.0/16',
|
||||
],
|
||||
[
|
||||
'adds a geoip token next to an existing negation',
|
||||
'!geoip:cn',
|
||||
['geoip:private'],
|
||||
ipKnown,
|
||||
'!geoip:cn, geoip:private',
|
||||
],
|
||||
[
|
||||
'ignores whitespace around field tokens',
|
||||
' google.com , geosite:google ',
|
||||
['geosite:google'],
|
||||
siteKnown,
|
||||
'google.com, geosite:google',
|
||||
],
|
||||
['clearing every known token can empty the field', 'geosite:google', [], siteKnown, ''],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, selected, known, expected) => {
|
||||
expect(mergeSelection(value, selected, known)).toBe(expected);
|
||||
});
|
||||
|
||||
it('round-trips with selectionFromValue', () => {
|
||||
const value = mergeSelection(
|
||||
'google.com, geosite:blabla',
|
||||
['geosite:google', 'geosite:cn'],
|
||||
siteKnown,
|
||||
);
|
||||
expect(value).toBe('google.com, geosite:blabla, geosite:google, geosite:cn');
|
||||
expect(selectionFromValue(value, siteKnown)).toEqual(['geosite:google', 'geosite:cn']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token matching tolerates the spellings Xray accepts', () => {
|
||||
const cases: Array<[string, string, string[], string]> = [
|
||||
[
|
||||
'an uppercase token is recognised instead of duplicated',
|
||||
'GEOSITE:GOOGLE',
|
||||
['geosite:google'],
|
||||
'GEOSITE:GOOGLE',
|
||||
],
|
||||
[
|
||||
'the long ext form of a default database is the same token as its shorthand',
|
||||
'ext:geosite.dat:google',
|
||||
['geosite:google'],
|
||||
'ext:geosite.dat:google',
|
||||
],
|
||||
[
|
||||
'clearing a category written in its long form removes it',
|
||||
'google.com, ext:geosite.dat:google',
|
||||
[],
|
||||
'google.com',
|
||||
],
|
||||
[
|
||||
'clearing a category written in uppercase removes it',
|
||||
'GEOSITE:GOOGLE, google.com',
|
||||
[],
|
||||
'google.com',
|
||||
],
|
||||
[
|
||||
'a token from a database that was never opened survives untouched',
|
||||
'ext:other.dat:x, geosite:google',
|
||||
['geosite:cn'],
|
||||
'ext:other.dat:x, geosite:cn',
|
||||
],
|
||||
['a value of separators alone collapses to empty', ',,, ,', [], ''],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, selected, expected) => {
|
||||
expect(mergeSelection(value, selected, siteKnown)).toBe(expected);
|
||||
});
|
||||
|
||||
it('seeds the selection from tokens written in another spelling', () => {
|
||||
expect(selectionFromValue('GEOSITE:GOOGLE, ext:geosite.dat:cn', siteKnown)).toEqual([
|
||||
'GEOSITE:GOOGLE',
|
||||
'ext:geosite.dat:cn',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
|
||||
import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
|
||||
import {
|
||||
renderWithProviders,
|
||||
fieldLabels,
|
||||
listSelectOptions,
|
||||
chooseSelectOption,
|
||||
} from './test-utils';
|
||||
|
||||
function renderModal(outbound: Record<string, unknown> | null = null) {
|
||||
return renderWithProviders(
|
||||
<OutboundFormModal
|
||||
open
|
||||
outbound={outbound}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('OutboundFormModal', () => {
|
||||
it('renders add mode without crashing', () => {
|
||||
renderModal(null);
|
||||
expect(document.querySelector('.ant-modal')).toBeTruthy();
|
||||
expect(fieldLabels().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
|
||||
renderModal(null);
|
||||
const protocols = listSelectOptions('protocol');
|
||||
expect(protocols.length).toBeGreaterThan(3);
|
||||
|
||||
const labelsByProto: Record<string, string[]> = {};
|
||||
for (const proto of protocols) {
|
||||
chooseSelectOption('protocol', proto);
|
||||
// Flush antd Form.useWatch('protocol') so protocol-specific fields render before
|
||||
// reading; otherwise every iteration sees the same default (vless) DOM.
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
labelsByProto[proto] = fieldLabels();
|
||||
}
|
||||
|
||||
// Distinct protocols must yield distinct field sets (a vacuous loop is all-identical).
|
||||
const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
|
||||
expect(distinctShapes.size).toBeGreaterThan(1);
|
||||
|
||||
// vless carries an Encryption field; wireguard does not — proves real protocol switching.
|
||||
if (labelsByProto.vless) {
|
||||
expect(labelsByProto.vless).toContain('Encryption');
|
||||
}
|
||||
if (labelsByProto.wireguard) {
|
||||
expect(labelsByProto.wireguard).not.toContain('Encryption');
|
||||
}
|
||||
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
|
||||
|
||||
it('saves a vless reverse outbound while reverse sniffing stays disabled', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
renderWithProviders(
|
||||
<OutboundFormModal
|
||||
open
|
||||
outbound={{
|
||||
protocol: 'vless',
|
||||
tag: 'reverse-out',
|
||||
settings: {
|
||||
vnext: [
|
||||
{
|
||||
address: 'example.com',
|
||||
port: 443,
|
||||
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
|
||||
},
|
||||
],
|
||||
reverse: { tag: 'r1', sniffing: {} },
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
}}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
|
||||
expect(ok).toBeTruthy();
|
||||
await act(async () => {
|
||||
fireEvent.click(ok);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const payload = onConfirm.mock.calls[0][0] as {
|
||||
settings: { reverse?: { tag?: string } };
|
||||
};
|
||||
expect(payload.settings.reverse?.tag).toBe('r1');
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { propagateOutboundTagRename } from '@/pages/xray/basics/helpers';
|
||||
|
||||
function baseTemplate(): XraySettingsValue {
|
||||
return {
|
||||
outbounds: [
|
||||
{ tag: 'To-External-Proxy', protocol: 'vless' },
|
||||
{ tag: 'direct', protocol: 'freedom' },
|
||||
],
|
||||
routing: {
|
||||
rules: [
|
||||
{
|
||||
type: 'field',
|
||||
inboundTag: ['iran-in'],
|
||||
outboundTag: 'To-External-Proxy',
|
||||
},
|
||||
],
|
||||
balancers: [
|
||||
{
|
||||
tag: 'lb-1',
|
||||
selector: ['To-External-Proxy', 'direct'],
|
||||
fallbackTag: 'To-External-Proxy',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('propagateOutboundTagRename', () => {
|
||||
it('updates routing rule outboundTag when outbound is renamed', () => {
|
||||
const t = baseTemplate();
|
||||
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||
expect(t.routing?.rules?.[0]?.outboundTag).toBe('external-vps');
|
||||
});
|
||||
|
||||
it('updates balancer selector and fallbackTag', () => {
|
||||
const t = baseTemplate();
|
||||
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||
expect(t.routing?.balancers?.[0]?.selector).toEqual(['external-vps', 'direct']);
|
||||
expect(t.routing?.balancers?.[0]?.fallbackTag).toBe('external-vps');
|
||||
});
|
||||
|
||||
it('updates sockopt dialerProxy references in other outbounds', () => {
|
||||
const t = baseTemplate();
|
||||
(
|
||||
t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }
|
||||
).streamSettings = {
|
||||
sockopt: { dialerProxy: 'To-External-Proxy' },
|
||||
};
|
||||
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||
const dialerProxy = (
|
||||
t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }
|
||||
).streamSettings?.sockopt?.dialerProxy;
|
||||
expect(dialerProxy).toBe('external-vps');
|
||||
});
|
||||
|
||||
it('is a no-op when old and new tags are equal', () => {
|
||||
const t = baseTemplate();
|
||||
propagateOutboundTagRename(t, 'To-External-Proxy', 'To-External-Proxy');
|
||||
expect(t.routing?.rules?.[0]?.outboundTag).toBe('To-External-Proxy');
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function settingsWithHiddenLoopback(): XraySettingsValue {
|
||||
return {
|
||||
outbounds: [
|
||||
{ tag: 'proxy-a', protocol: 'vmess' },
|
||||
{ tag: '_bl_bal1', protocol: 'loopback' },
|
||||
{ tag: 'proxy-b', protocol: 'vmess' },
|
||||
],
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
// The visible rows hide the balancer-loopback outbound, so every row action
|
||||
// must translate the positional row index back to the outbound's real index
|
||||
// in the full array. "Move to top" on the second visible row (proxy-b, real
|
||||
// index 2) must move proxy-b — not the hidden loopback at positional index 1.
|
||||
describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||
function renderTab(isMobile: boolean) {
|
||||
const setTemplateSettings = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OutboundsTab
|
||||
templateSettings={settingsWithHiddenLoopback()}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
outboundsTraffic={[]}
|
||||
inboundTags={[]}
|
||||
isMobile={isMobile}
|
||||
onResetTraffic={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
onShowPia={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return setTemplateSettings;
|
||||
}
|
||||
|
||||
function expectMovedProxyBToTop(setTemplateSettings: ReturnType<typeof vi.fn>) {
|
||||
expect(setTemplateSettings).toHaveBeenCalled();
|
||||
const updater = setTemplateSettings.mock.calls.at(-1)?.[0];
|
||||
expect(typeof updater).toBe('function');
|
||||
const next = updater(structuredClone(settingsWithHiddenLoopback()));
|
||||
expect(next.outbounds.map((o: { tag?: string }) => o.tag)).toEqual([
|
||||
'proxy-b',
|
||||
'proxy-a',
|
||||
'_bl_bal1',
|
||||
]);
|
||||
}
|
||||
|
||||
async function clickMoveToTop(scope: Element) {
|
||||
const moreButton = scope.querySelector('button[aria-label="more"]') as HTMLButtonElement;
|
||||
expect(moreButton).toBeTruthy();
|
||||
fireEvent.click(moreButton);
|
||||
const item = await waitFor(() => {
|
||||
const found = [...document.querySelectorAll('.ant-dropdown-menu-item')].find((el) =>
|
||||
el.textContent?.includes('Move to top'),
|
||||
);
|
||||
expect(found).toBeTruthy();
|
||||
return found as HTMLElement;
|
||||
});
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
it('acts on the real array index from the desktop table', async () => {
|
||||
const setTemplateSettings = renderTab(false);
|
||||
const tbody = document.querySelector('.ant-table-tbody');
|
||||
const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
|
||||
expect(tableRows.length).toBe(2);
|
||||
await clickMoveToTop(tableRows[1]);
|
||||
expectMovedProxyBToTop(setTemplateSettings);
|
||||
});
|
||||
|
||||
it('acts on the real array index from the mobile card list as well', async () => {
|
||||
const setTemplateSettings = renderTab(true);
|
||||
const cards = document.querySelectorAll('.outbound-card');
|
||||
expect(cards.length).toBe(2);
|
||||
await clickMoveToTop(cards[1]);
|
||||
expectMovedProxyBToTop(setTemplateSettings);
|
||||
});
|
||||
});
|
||||
@@ -1,391 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import PiaModal from '@/pages/xray/overrides/PiaModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const ACCOUNT = { username: 'p1234567', accountHint: 'p*****67' };
|
||||
const COUNTRIES = [{ code: 'US' }, { code: 'DE' }, { code: 'AL' }];
|
||||
const SERVERS = {
|
||||
regions: [
|
||||
{ id: 'us-east', name: 'US East' },
|
||||
{ id: 'us-west', name: 'US West' },
|
||||
{ id: 'al', name: 'Albania' },
|
||||
],
|
||||
servers: [
|
||||
{ hostname: 'useast1', ip: '198.51.100.10', regionId: 'us-east', regionName: 'US East' },
|
||||
{ hostname: 'uswest1', ip: '198.51.100.30', regionId: 'us-west', regionName: 'US West' },
|
||||
{
|
||||
hostname: 'Server-12406-1a',
|
||||
ip: '198.51.100.40',
|
||||
regionId: 'al',
|
||||
regionName: 'Albania',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function piaApiPost(url: string, data?: unknown) {
|
||||
if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
|
||||
if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
|
||||
if (url === '/panel/api/xray/pia/servers') {
|
||||
const code = (data as { countryCode?: string } | undefined)?.countryCode?.toUpperCase();
|
||||
if (code === 'AL') {
|
||||
return new Msg(true, '', {
|
||||
regions: [SERVERS.regions[2]],
|
||||
servers: [SERVERS.servers[2]],
|
||||
});
|
||||
}
|
||||
if (code === 'US') {
|
||||
return new Msg(true, '', {
|
||||
regions: SERVERS.regions.slice(0, 2),
|
||||
servers: SERVERS.servers.slice(0, 2),
|
||||
});
|
||||
}
|
||||
return new Msg(true, '', { regions: [], servers: [] });
|
||||
}
|
||||
if (url === '/panel/api/xray/pia/addKey') {
|
||||
const hostname = (data as { hostname?: string } | undefined)?.hostname;
|
||||
if (hostname === 'uswest1') {
|
||||
return new Msg(true, '', {
|
||||
tag: 'pia-us-west-uswest1',
|
||||
hostname: 'uswest1',
|
||||
secretKey: 'secret-west',
|
||||
address: '10.8.0.2/32',
|
||||
publicKey: 'pubkey-west',
|
||||
endpoint: '198.51.100.30:1337',
|
||||
});
|
||||
}
|
||||
if (hostname === 'Server-12406-1a' || hostname === 'pia-al-server-12406-1a') {
|
||||
return new Msg(true, '', {
|
||||
tag: 'pia-al-server-12406-1a',
|
||||
hostname: 'Server-12406-1a',
|
||||
secretKey: 'secret-al',
|
||||
address: '10.8.0.3/32',
|
||||
publicKey: 'pubkey-al',
|
||||
endpoint: '198.51.100.40:1337',
|
||||
});
|
||||
}
|
||||
if (hostname === 'useast1' || hostname === 'pia-us-east-useast1') {
|
||||
return new Msg(true, '', {
|
||||
tag: 'pia-us-east-useast1',
|
||||
hostname: 'useast1',
|
||||
secretKey: 'secret',
|
||||
address: '10.8.0.1/32',
|
||||
publicKey: 'pubkey',
|
||||
endpoint: '198.51.100.10:1337',
|
||||
});
|
||||
}
|
||||
return new Msg(false, `Unexpected addKey hostname ${hostname}`, null);
|
||||
}
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
}
|
||||
|
||||
function mockPiaApi() {
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) =>
|
||||
piaApiPost(url, data),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleOptions(): HTMLElement[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseOption(testId: string, labelPart: string) {
|
||||
const node = screen.getByTestId(testId);
|
||||
const select = node.closest('.ant-select') ?? node;
|
||||
const selector = select.querySelector('.ant-select-selector') ?? select;
|
||||
fireEvent.mouseDown(selector);
|
||||
await waitFor(() => expect(visibleOptions().length).toBeGreaterThan(0));
|
||||
const option = visibleOptions().find((item) =>
|
||||
(item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
|
||||
);
|
||||
if (!option) throw new Error(`Missing option containing ${labelPart}`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
async function clickAddOutbound() {
|
||||
const addButton = await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
|
||||
return btn;
|
||||
});
|
||||
fireEvent.click(addButton);
|
||||
}
|
||||
|
||||
function expectPiaOutbound(
|
||||
outbound: Record<string, unknown>,
|
||||
want: {
|
||||
tag: string;
|
||||
hostname: string;
|
||||
secretKey: string;
|
||||
address: string;
|
||||
publicKey: string;
|
||||
endpoint: string;
|
||||
},
|
||||
) {
|
||||
expect(outbound).toMatchObject({
|
||||
tag: want.tag,
|
||||
piaHostname: want.hostname,
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: want.secretKey,
|
||||
address: [want.address],
|
||||
mtu: 1420,
|
||||
noKernelTun: true,
|
||||
peers: [
|
||||
{
|
||||
publicKey: want.publicKey,
|
||||
endpoint: want.endpoint,
|
||||
allowedIPs: ['0.0.0.0/0'],
|
||||
keepAlive: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function PiaHarness({ onAdded }: { onAdded?: (outbound: Record<string, unknown>) => void }) {
|
||||
const [outbounds, setOutbounds] = useState<Record<string, unknown>[]>([]);
|
||||
return (
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={(outbound) => {
|
||||
onAdded?.(outbound);
|
||||
setOutbounds((prev) => [...prev, outbound]);
|
||||
}}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PIA modal', () => {
|
||||
it('shows username and password when not signed in', async () => {
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/pia/data') return new Msg(true, '', null);
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('PIA username')).toBeTruthy());
|
||||
expect(screen.getByPlaceholderText('PIA password')).toBeTruthy();
|
||||
expect(screen.getByRole('dialog', { name: 'Private Internet Access WireGuard' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Log In/ })).toBeTruthy();
|
||||
expect(screen.queryByTestId('pia-country-select')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds two WireGuard outbounds for different servers', async () => {
|
||||
mockPiaApi();
|
||||
const added: Record<string, unknown>[] = [];
|
||||
renderWithProviders(<PiaHarness onAdded={(outbound) => added.push(outbound)} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await clickAddOutbound();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('pia-added-table')).toBeTruthy());
|
||||
expect(screen.getByText('pia-us-east-useast1')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Add outbound/ })).toBeTruthy();
|
||||
|
||||
await chooseOption('pia-server-select', 'uswest1');
|
||||
await clickAddOutbound();
|
||||
await waitFor(() => expect(screen.getByText('pia-us-west-uswest1')).toBeTruthy());
|
||||
expect(added).toHaveLength(2);
|
||||
expectPiaOutbound(added[0], {
|
||||
tag: 'pia-us-east-useast1',
|
||||
hostname: 'useast1',
|
||||
secretKey: 'secret',
|
||||
address: '10.8.0.1/32',
|
||||
publicKey: 'pubkey',
|
||||
endpoint: '198.51.100.10:1337',
|
||||
});
|
||||
expectPiaOutbound(added[1], {
|
||||
tag: 'pia-us-west-uswest1',
|
||||
hostname: 'uswest1',
|
||||
secretKey: 'secret-west',
|
||||
address: '10.8.0.2/32',
|
||||
publicKey: 'pubkey-west',
|
||||
endpoint: '198.51.100.30:1337',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Add when the selected server is already in the list', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
expect(screen.getByText(/Use Reset to renew its key/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resets an existing PIA outbound in place', async () => {
|
||||
const onResetOutbound = vi.fn();
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1', piaHostname: 'useast1' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('pia-reset-0')).toBeTruthy());
|
||||
fireEvent.click(screen.getByTestId('pia-reset-0'));
|
||||
await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
|
||||
const payload = onResetOutbound.mock.calls[0][0] as {
|
||||
index: number;
|
||||
outbound: { tag: string; piaHostname: string; settings: { secretKey: string } };
|
||||
oldTag?: string;
|
||||
newTag: string;
|
||||
};
|
||||
expect(payload.index).toBe(0);
|
||||
expect(payload.oldTag).toBe('pia-us-east-useast1');
|
||||
expect(payload.newTag).toBe('pia-us-east-useast1');
|
||||
expectPiaOutbound(payload.outbound as Record<string, unknown>, {
|
||||
tag: 'pia-us-east-useast1',
|
||||
hostname: 'useast1',
|
||||
secretKey: 'secret',
|
||||
address: '10.8.0.1/32',
|
||||
publicKey: 'pubkey',
|
||||
endpoint: '198.51.100.10:1337',
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Add for a hyphenated cn when only the tag remains', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'AL');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Add when only the outbound tag remains', async () => {
|
||||
mockPiaApi();
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-us-east-useast1' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByRole('button', { name: /Add outbound/ });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets from the outbound tag when piaHostname was stripped', async () => {
|
||||
const onResetOutbound = vi.fn();
|
||||
const posts: unknown[] = [];
|
||||
mockPiaApi();
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string, data?: unknown) => {
|
||||
if (url === '/panel/api/xray/pia/addKey') posts.push(data);
|
||||
return piaApiPost(url, data);
|
||||
});
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [{ tag: 'pia-al-server-12406-1a' }] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={vi.fn()}
|
||||
onResetOutbound={onResetOutbound}
|
||||
/>,
|
||||
);
|
||||
|
||||
const reset = await waitFor(() => screen.getByTestId('pia-reset-0'));
|
||||
expect((reset as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(reset);
|
||||
await waitFor(() => expect(onResetOutbound).toHaveBeenCalledTimes(1));
|
||||
expect(posts).toEqual([{ hostname: 'pia-al-server-12406-1a' }]);
|
||||
expectPiaOutbound(onResetOutbound.mock.calls[0][0].outbound as Record<string, unknown>, {
|
||||
tag: 'pia-al-server-12406-1a',
|
||||
hostname: 'Server-12406-1a',
|
||||
secretKey: 'secret-al',
|
||||
address: '10.8.0.3/32',
|
||||
publicKey: 'pubkey-al',
|
||||
endpoint: '198.51.100.40:1337',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add an outbound when addKey omits WireGuard fields', async () => {
|
||||
const onAddOutbound = vi.fn();
|
||||
vi.mocked(HttpUtil.post).mockImplementation(async (url: string) => {
|
||||
if (url === '/panel/api/xray/pia/data') return new Msg(true, '', ACCOUNT);
|
||||
if (url === '/panel/api/xray/pia/countries') return new Msg(true, '', COUNTRIES);
|
||||
if (url === '/panel/api/xray/pia/servers') return new Msg(true, '', SERVERS);
|
||||
if (url === '/panel/api/xray/pia/addKey') {
|
||||
return new Msg(true, '', { tag: 'pia-us-east-useast1', hostname: 'useast1' });
|
||||
}
|
||||
return new Msg(false, `Unexpected POST ${url}`, null);
|
||||
});
|
||||
renderWithProviders(
|
||||
<PiaModal
|
||||
open
|
||||
templateSettings={{ outbounds: [] }}
|
||||
onClose={vi.fn()}
|
||||
onAddOutbound={onAddOutbound}
|
||||
onResetOutbound={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
|
||||
await chooseOption('pia-country-select', 'US');
|
||||
await waitFor(() => expect(screen.getByTestId('pia-server-select')).toBeTruthy());
|
||||
await clickAddOutbound();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Could not build the PIA outbound. Try again.')).toBeTruthy(),
|
||||
);
|
||||
expect(onAddOutbound).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
import { getDefaultOutboundTag, setDefaultOutboundTag } from '@/pages/xray/basics/helpers';
|
||||
|
||||
function tpl(
|
||||
outbounds: Array<{ tag?: string; protocol?: string; settings?: unknown }>,
|
||||
rules: Array<{ type: string; outboundTag?: string; ip?: string[]; protocol?: string[] }> = [],
|
||||
): XraySettingsValue {
|
||||
return { outbounds, routing: { rules } } as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('routing default outbound', () => {
|
||||
it('reads first outbound tag', () => {
|
||||
expect(
|
||||
getDefaultOutboundTag(
|
||||
tpl([
|
||||
{ tag: 'warp', protocol: 'socks' },
|
||||
{ tag: 'direct', protocol: 'freedom' },
|
||||
]),
|
||||
),
|
||||
).toBe('warp');
|
||||
expect(getDefaultOutboundTag(tpl([]))).toBe('direct');
|
||||
});
|
||||
|
||||
it('moves existing outbound to first position', () => {
|
||||
const tt = tpl([
|
||||
{ tag: 'direct', protocol: 'freedom' },
|
||||
{ tag: 'warp', protocol: 'socks' },
|
||||
{ tag: 'blocked', protocol: 'blackhole' },
|
||||
]);
|
||||
setDefaultOutboundTag(tt, 'warp');
|
||||
expect(tt.outbounds!.map((o) => o?.tag)).toEqual(['warp', 'direct', 'blocked']);
|
||||
});
|
||||
|
||||
it('creates blocked outbound when missing', () => {
|
||||
const tt = tpl([{ tag: 'direct', protocol: 'freedom' }]);
|
||||
setDefaultOutboundTag(tt, 'blocked');
|
||||
expect(tt.outbounds![0]?.tag).toBe('blocked');
|
||||
expect(tt.outbounds![0]?.protocol).toBe('blackhole');
|
||||
});
|
||||
|
||||
it('does not prune direct when only blocked rules reference an outbound', () => {
|
||||
const tt = tpl(
|
||||
[
|
||||
{ tag: 'direct', protocol: 'freedom', settings: { domainStrategy: 'AsIs' } },
|
||||
{ tag: 'blocked', protocol: 'blackhole' },
|
||||
{ tag: 'warp', protocol: 'socks' },
|
||||
],
|
||||
[
|
||||
{ type: 'field', ip: ['geoip:private'], outboundTag: 'blocked' },
|
||||
{ type: 'field', protocol: ['bittorrent'], outboundTag: 'blocked' },
|
||||
],
|
||||
);
|
||||
setDefaultOutboundTag(tt, 'warp');
|
||||
expect(tt.outbounds!.map((o) => o?.tag)).toEqual(['warp', 'direct', 'blocked']);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import RoutingTab from '@/pages/xray/routing/RoutingTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function settingsWithHiddenLoopback(): XraySettingsValue {
|
||||
return {
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', outboundTag: 'a', enabled: true },
|
||||
{ type: 'field', inboundTag: ['_bl_bal1'], outboundTag: 'b', enabled: true },
|
||||
{ type: 'field', outboundTag: 'c', enabled: true },
|
||||
],
|
||||
},
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('RoutingTab hidden-loopback index mapping', () => {
|
||||
it('toggles the visible rule, not the hidden loopback rule that precedes it', () => {
|
||||
const setTemplateSettings = vi.fn();
|
||||
const initial = settingsWithHiddenLoopback();
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RoutingTab
|
||||
templateSettings={initial}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
inboundTags={[]}
|
||||
clientReverseTags={[]}
|
||||
isMobile={false}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ }));
|
||||
|
||||
const switches = document.querySelectorAll('.routing-table .ant-switch');
|
||||
expect(switches.length).toBe(2);
|
||||
|
||||
fireEvent.click(switches[1]);
|
||||
|
||||
expect(setTemplateSettings).toHaveBeenCalledTimes(1);
|
||||
const updater = setTemplateSettings.mock.calls[0][0] as (
|
||||
prev: XraySettingsValue,
|
||||
) => XraySettingsValue;
|
||||
const next = updater(initial);
|
||||
const rules = (next.routing as { rules: Array<{ enabled?: boolean }> }).rules;
|
||||
|
||||
expect(rules[2].enabled).toBe(false);
|
||||
expect(rules[1].enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,425 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyBalancerDeletion,
|
||||
applyOutboundDeletion,
|
||||
planBalancerDeletion,
|
||||
planOutboundDeletion,
|
||||
} from '@/pages/xray/reference-cleanup';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
function tpl(parts: Record<string, unknown>): XraySettingsValue {
|
||||
return parts as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
function dialerProxyOf(tt: XraySettingsValue, tag: string): string | undefined {
|
||||
const o = tt.outbounds?.find((x) => x?.tag === tag);
|
||||
return (o as { streamSettings?: { sockopt?: { dialerProxy?: string } } } | undefined)
|
||||
?.streamSettings?.sockopt?.dialerProxy;
|
||||
}
|
||||
|
||||
describe('outbound deletion', () => {
|
||||
it('drops a rule whose only destination was the deleted outbound', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in-443'], outboundTag: 'proxy-us' }],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
expect(tt.outbounds).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rule that still has a balancer, dropping only the dead outboundTag', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'eu-pool' }],
|
||||
balancers: [{ tag: 'eu-pool', selector: ['direct'] }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].outboundTag).toBeUndefined();
|
||||
expect(tt.routing!.rules![0].balancerTag).toBe('eu-pool');
|
||||
});
|
||||
|
||||
it('reduces a multi-target selector and leaves the balancer and its rules intact', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }, { tag: 'proxy-uk' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'] }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].balancerTag).toBe('pool');
|
||||
expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['proxy-uk']);
|
||||
});
|
||||
|
||||
it('cascade-removes a balancer whose selector is emptied, repairing its rules', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' },
|
||||
{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' },
|
||||
],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 0);
|
||||
expect(impact.balancers).toEqual([{ tag: 'pool', reason: 'selectorEmptied' }]);
|
||||
expect(impact.rules).toEqual([
|
||||
{ index: 0, label: '#1', fate: 'removed' },
|
||||
{ index: 1, label: '#2', fate: 'modified', keeps: 'direct' },
|
||||
]);
|
||||
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].outboundTag).toBe('direct');
|
||||
expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cascade-removes the burst observer when deleting an outbound removes the last leastLoad balancer', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'll-out' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['ll-out'] },
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 0);
|
||||
expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
|
||||
expect(impact.burst).toBe(true);
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('cascade-switches from burst to regular observer when only leastPing remains', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 1);
|
||||
expect(impact.balancers).toEqual([{ tag: 'll', reason: 'selectorEmptied' }]);
|
||||
expect(impact.burst).toBe(true);
|
||||
applyOutboundDeletion(tt, 1);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('cascade-keeps burst observer when leastPing is removed but leastLoad remains', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'lp-out' }, { tag: 'll-out' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planOutboundDeletion(tt, 0);
|
||||
expect(impact.balancers).toEqual([{ tag: 'lp', reason: 'selectorEmptied' }]);
|
||||
expect(impact.burst).toBe(false);
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
|
||||
'll-out',
|
||||
]);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [
|
||||
{ tag: 'proxy-us' },
|
||||
{ tag: 'chain', streamSettings: { sockopt: { dialerProxy: 'proxy-us' } } },
|
||||
],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'], fallbackTag: 'proxy-us' }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
|
||||
expect(tt.routing!.balancers![0].fallbackTag).toBe('');
|
||||
expect(dialerProxyOf(tt, 'chain')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never cascade-removes a tagless balancer (an empty tag must not match others)', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: '', selector: ['proxy-us'] },
|
||||
{ tag: '', selector: ['keep-me'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not throw on null entries in rules/balancers/outbounds (unvalidated config)', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }, null],
|
||||
routing: {
|
||||
rules: [null, { type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' }],
|
||||
balancers: [null, { tag: 'pool', selector: ['keep'] }],
|
||||
},
|
||||
});
|
||||
expect(() => planOutboundDeletion(tt, 0)).not.toThrow();
|
||||
expect(() => applyOutboundDeletion(tt, 0)).not.toThrow();
|
||||
expect(tt.routing!.balancers).toEqual([{ tag: 'pool', selector: ['keep'] }]);
|
||||
});
|
||||
|
||||
it('drops a rule that loses BOTH destinations in one cascade', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans a disabled rule too', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', enabled: false, inboundTag: ['in'], outboundTag: 'proxy-us' }],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves unrelated rules and outbounds untouched', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }, { tag: 'direct' }],
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' },
|
||||
{ type: 'field', inboundTag: ['in2'], outboundTag: 'direct' },
|
||||
],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].outboundTag).toBe('direct');
|
||||
expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['direct']);
|
||||
});
|
||||
|
||||
it('removes a referenced outbound with no rules and reports an empty impact', () => {
|
||||
const tt = tpl({ outbounds: [{ tag: 'lonely' }], routing: { rules: [], balancers: [] } });
|
||||
expect(planOutboundDeletion(tt, 0)).toEqual({
|
||||
rules: [],
|
||||
balancers: [],
|
||||
observatory: false,
|
||||
burst: false,
|
||||
});
|
||||
applyOutboundDeletion(tt, 0);
|
||||
expect(tt.outbounds).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses ruleTag as the impact label when present', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'x' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', ruleTag: 'block-ads', outboundTag: 'x' }],
|
||||
balancers: [],
|
||||
},
|
||||
});
|
||||
expect(planOutboundDeletion(tt, 0).rules[0].label).toBe('block-ads');
|
||||
});
|
||||
|
||||
it('does not mutate the template when only planning', () => {
|
||||
const tt = tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['proxy-us'] },
|
||||
});
|
||||
const before = JSON.stringify(tt);
|
||||
planOutboundDeletion(tt, 0);
|
||||
expect(JSON.stringify(tt)).toBe(before);
|
||||
});
|
||||
|
||||
it('predicts the surviving rule count exactly (plan/apply parity)', () => {
|
||||
const make = () =>
|
||||
tpl({
|
||||
outbounds: [{ tag: 'proxy-us' }],
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', inboundTag: ['a'], outboundTag: 'proxy-us' },
|
||||
{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' },
|
||||
{ type: 'field', inboundTag: ['b'], outboundTag: 'direct' },
|
||||
{ type: 'field', inboundTag: ['c'], balancerTag: 'pool' },
|
||||
],
|
||||
balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
|
||||
},
|
||||
});
|
||||
const planned = make();
|
||||
const applied = make();
|
||||
const total = planned.routing!.rules!.length;
|
||||
const removed = planOutboundDeletion(planned, 0).rules.filter(
|
||||
(r) => r.fate === 'removed',
|
||||
).length;
|
||||
applyOutboundDeletion(applied, 0);
|
||||
expect(applied.routing!.rules!.length).toBe(total - removed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balancer deletion', () => {
|
||||
it('drops a rule whose only destination was the deleted balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['a'] }],
|
||||
},
|
||||
});
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
expect(tt.routing!.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rule that still has an outbound, dropping only the dead balancerTag', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' }],
|
||||
balancers: [{ tag: 'pool', selector: ['a'] }],
|
||||
},
|
||||
});
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
|
||||
expect(tt.routing!.rules![0].outboundTag).toBe('direct');
|
||||
});
|
||||
|
||||
it('reports and removes the observer when deleting the last leastPing balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }],
|
||||
},
|
||||
observatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).observatory).toBe(true);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports and removes the burst observer when deleting the last leastLoad balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [{ tag: 'll', selector: ['a'], strategy: { type: 'leastLoad' } }],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports and removes the burst observer when deleting the last fallback balancer', () => {
|
||||
const tt = tpl({
|
||||
routing: { rules: [], balancers: [{ tag: 'rf', selector: ['a'], fallbackTag: 'direct' }] },
|
||||
burstObservatory: { subjectSelector: ['a'] },
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).burst).toBe(true);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect(tt.routing!.balancers).toEqual([]);
|
||||
});
|
||||
|
||||
it('switches from burst to regular observer when the deleted balancer was the last burst-required one', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planBalancerDeletion(tt, 1);
|
||||
expect(impact.burst).toBe(true);
|
||||
expect(impact.observatory).toBe(false);
|
||||
applyBalancerDeletion(tt, 1);
|
||||
expect(tt.burstObservatory).toBeUndefined();
|
||||
expect((tt.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['lp-out']);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps burst observer when deleting leastPing but a burst-required balancer remains', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [],
|
||||
balancers: [
|
||||
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
],
|
||||
},
|
||||
burstObservatory: { subjectSelector: ['lp-out', 'll-out'] },
|
||||
});
|
||||
const impact = planBalancerDeletion(tt, 0);
|
||||
expect(impact.burst).toBe(false);
|
||||
expect(impact.observatory).toBe(false);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.observatory).toBeUndefined();
|
||||
expect((tt.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
|
||||
'll-out',
|
||||
]);
|
||||
expect(tt.routing!.balancers).toEqual([
|
||||
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not report rules when the deleted balancer is unreferenced', () => {
|
||||
const tt = tpl({
|
||||
routing: {
|
||||
rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }],
|
||||
balancers: [{ tag: 'pool', selector: ['a'] }],
|
||||
},
|
||||
});
|
||||
expect(planBalancerDeletion(tt, 0).rules).toEqual([]);
|
||||
applyBalancerDeletion(tt, 0);
|
||||
expect(tt.routing!.rules).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
import { chooseSelectOption, renderWithProviders } from './test-utils';
|
||||
|
||||
describe('RuleFormModal edit preserves unsurfaced fields', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RuleFormModal
|
||||
open
|
||||
rule={{ type: 'field', outboundTag: 'block', ruleTag: 'my-tag', enabled: true }}
|
||||
inboundTags={[]}
|
||||
outboundTags={['block']}
|
||||
balancerTags={[]}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm.mock.calls[0][0]).toMatchObject({ ruleTag: 'my-tag' });
|
||||
});
|
||||
|
||||
it('selects existing clients for the user criterion', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(
|
||||
new Msg(true, '', [
|
||||
{ id: 1, email: 'alice@example.com' },
|
||||
{ id: 2, email: 'bob@example.com' },
|
||||
]),
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<RuleFormModal
|
||||
open
|
||||
rule={null}
|
||||
inboundTags={[]}
|
||||
outboundTags={['direct']}
|
||||
balancerTags={[]}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
{ queryClient },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(HttpUtil.get).toHaveBeenCalledWith('/panel/api/clients/list', undefined, {
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(queryClient.getQueryData(keys.clients.all())).toHaveLength(2));
|
||||
const userField = screen.getByLabelText('User');
|
||||
expect(screen.getByText('Select users')).toBeTruthy();
|
||||
chooseSelectOption(userField.id, 'alice@example.com');
|
||||
chooseSelectOption(userField.id, 'bob@example.com');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: ['alice@example.com', 'bob@example.com'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a saved user that is no longer in the client list', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(
|
||||
new Msg(true, '', [{ id: 1, email: 'alice@example.com' }]),
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<RuleFormModal
|
||||
open
|
||||
rule={{ type: 'field', user: ['removed@example.com'], outboundTag: 'direct' }}
|
||||
inboundTags={[]}
|
||||
outboundTags={['direct']}
|
||||
balancerTags={[]}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('removed@example.com')).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: ['removed@example.com'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a custom user identifier that is not a client record', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(
|
||||
new Msg(true, '', [{ id: 1, email: 'alice@example.com' }]),
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
renderWithProviders(
|
||||
<RuleFormModal
|
||||
open
|
||||
rule={null}
|
||||
inboundTags={[]}
|
||||
outboundTags={['direct']}
|
||||
balancerTags={[]}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userField = screen.getByLabelText('User');
|
||||
fireEvent.change(userField, { target: { value: 'office-proxy,' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ user: ['office-proxy'] }));
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useXraySetting } from '@/hooks/useXraySetting';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
function xrayPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
xraySetting: {},
|
||||
inboundTags: [],
|
||||
clientReverseTags: [],
|
||||
subscriptionOutbounds: [],
|
||||
subscriptionOutboundTags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
});
|
||||
|
||||
describe('useXraySetting', () => {
|
||||
it('refreshes server-derived outbounds while the editor is dirty', async () => {
|
||||
let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] });
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useXraySetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.setXraySetting('{"outbounds":[]}'));
|
||||
payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] });
|
||||
await act(async () => result.current.fetchAll());
|
||||
|
||||
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
|
||||
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeWarpRotation } from '@/pages/xray/overrides/WarpModal';
|
||||
|
||||
const clientId = btoa(String.fromCharCode(1, 2, 3));
|
||||
|
||||
function rotatedConfig(
|
||||
overrides: { public_key?: string; host?: string; v4?: string; v6?: string } = {},
|
||||
) {
|
||||
return {
|
||||
config: {
|
||||
client_id: clientId,
|
||||
interface: {
|
||||
addresses: { v4: overrides.v4 ?? '172.16.0.2', v6: overrides.v6 ?? '2606:4700::2' },
|
||||
},
|
||||
peers: [
|
||||
{
|
||||
public_key: overrides.public_key ?? 'newPub',
|
||||
endpoint: { host: overrides.host ?? 'engage.cloudflareclient.com:2408' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeWarpRotation', () => {
|
||||
it('patches only rotated fields and preserves user customizations', () => {
|
||||
const existing = {
|
||||
tag: 'warp',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
mtu: 1280,
|
||||
secretKey: 'oldSecret',
|
||||
address: ['172.16.0.2/32'],
|
||||
reserved: [9, 9, 9],
|
||||
domainStrategy: 'ForceIPv4',
|
||||
noKernelTun: false,
|
||||
peers: [
|
||||
{
|
||||
publicKey: 'oldPub',
|
||||
endpoint: 'engage.cloudflareclient.com:2408',
|
||||
keepAlive: 25,
|
||||
allowedIPs: ['10.0.0.0/24'],
|
||||
preSharedKey: 'psk',
|
||||
},
|
||||
{ publicKey: 'extraPeer', endpoint: 'extra.test:51820' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeWarpRotation(
|
||||
existing,
|
||||
{ private_key: 'newSecret' },
|
||||
rotatedConfig({
|
||||
public_key: 'newPub',
|
||||
host: 'engage.cloudflareclient.com:2408',
|
||||
v4: '172.16.0.9',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(merged).not.toBeNull();
|
||||
const settings = (merged as { settings: Record<string, unknown> }).settings;
|
||||
expect(settings.secretKey).toBe('newSecret');
|
||||
expect(settings.address).toEqual(['172.16.0.9/32', '2606:4700::2/128']);
|
||||
expect(settings.reserved).toEqual([1, 2, 3]);
|
||||
const peers = settings.peers as Array<Record<string, unknown>>;
|
||||
expect(peers[0].publicKey).toBe('newPub');
|
||||
expect(peers[0].endpoint).toBe('engage.cloudflareclient.com:2408');
|
||||
expect(peers[0].keepAlive).toBe(25);
|
||||
expect(peers[0].allowedIPs).toEqual(['10.0.0.0/24']);
|
||||
expect(peers[0].preSharedKey).toBe('psk');
|
||||
expect(peers[1]).toEqual({ publicKey: 'extraPeer', endpoint: 'extra.test:51820' });
|
||||
expect(settings.mtu).toBe(1280);
|
||||
expect(settings.domainStrategy).toBe('ForceIPv4');
|
||||
expect(settings.noKernelTun).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mutate the existing outbound object', () => {
|
||||
const existing = {
|
||||
tag: 'warp',
|
||||
protocol: 'wireguard',
|
||||
settings: {
|
||||
secretKey: 'oldSecret',
|
||||
address: ['172.16.0.2/32'],
|
||||
reserved: [9, 9, 9],
|
||||
peers: [{ publicKey: 'oldPub', endpoint: 'old:1', keepAlive: 25 }],
|
||||
},
|
||||
};
|
||||
const snapshot = JSON.parse(JSON.stringify(existing));
|
||||
|
||||
mergeWarpRotation(existing, { private_key: 'newSecret' }, rotatedConfig());
|
||||
|
||||
expect(existing).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('returns null when the rotation response has no peer', () => {
|
||||
expect(mergeWarpRotation(undefined, null, { config: { peers: [] } })).toBeNull();
|
||||
expect(mergeWarpRotation(undefined, null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('seeds a default warp outbound when none existed yet', () => {
|
||||
const merged = mergeWarpRotation(undefined, { private_key: 'newSecret' }, rotatedConfig());
|
||||
expect(merged).not.toBeNull();
|
||||
expect((merged as Record<string, unknown>).tag).toBe('warp');
|
||||
expect((merged as Record<string, unknown>).protocol).toBe('wireguard');
|
||||
const settings = (merged as { settings: Record<string, unknown> }).settings;
|
||||
expect(settings.secretKey).toBe('newSecret');
|
||||
expect(settings.peers).toEqual([
|
||||
{ publicKey: 'newPub', endpoint: 'engage.cloudflareclient.com:2408' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
type ListenerMap = { listeners: Map<string, Set<(payload: unknown) => void>> };
|
||||
|
||||
describe('websocket bridge outbounds handler', () => {
|
||||
let originalWS: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWS = globalThis.WebSocket;
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWS;
|
||||
});
|
||||
|
||||
it('ignores a non-array outbounds push instead of poisoning the cache', () => {
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
renderHook(() => useWebSocketBridge(), { wrapper });
|
||||
|
||||
const client = getSharedWebSocketClient() as unknown as ListenerMap;
|
||||
const handlers = client.listeners.get('outbounds');
|
||||
expect(handlers && handlers.size).toBeGreaterThan(0);
|
||||
|
||||
for (const handler of handlers ?? []) handler({ not: 'an array' });
|
||||
|
||||
expect(queryClient.getQueryData(keys.xray.outboundsTraffic())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { ClientPageResponseSchema } from '@/schemas/client';
|
||||
import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -45,20 +44,3 @@ describe('parseMsg', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchXrayConfig', () => {
|
||||
it('keeps a malformed xray payload available for repair', async () => {
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(
|
||||
new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })),
|
||||
);
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] xray/ config payload failed validation',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user