mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-01 19:52:09 +03:00
feat(happ): add routing editor, optional ad blocking, and LAN bypass preset (#6545)
* feat(happ): make ad blocking optional in routing presets Add an independent AdBlock toggle for Iran, China, and global presets, applied only when generating routing rules. Update the China preset to Bypass-CN and cover preset behavior and localized controls. * feat: add visual routing editor with JSON support and localization updates - Implemented a new modal for editing routing profiles with basic and advanced tabs. - Added functionality to load, parse, and generate routing profiles in JSON format. - Enhanced user experience with validation and error handling for JSON input. - Updated translations for Russian, Turkish, Ukrainian, Vietnamese, Chinese (Simplified and Traditional) to include new routing editor terms. - Created helper functions for managing routing profiles and generating deep links. - Added unit tests for routing editor functionalities and JSON handling. * feat: update routing editor to preserve null lists in profiles and enhance validation messages
This commit is contained in:
@@ -1,14 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
import { buildHappPresetDeeplink, toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
describe('Happ presets and helpers', () => {
|
||||
it('correctly parses comma and newline separated lists', () => {
|
||||
const raw = 'domain:ir\nregexp:.*\\.ir$\n, example.com, , google.com';
|
||||
const parsed = parseList(raw);
|
||||
expect(parsed).toEqual(['domain:ir', 'regexp:.*\\.ir$', 'example.com', 'google.com']);
|
||||
});
|
||||
|
||||
it('generates valid happ://routing/off for off preset', () => {
|
||||
const link = buildHappPresetDeeplink('off');
|
||||
expect(link).toBe('happ://routing/off');
|
||||
@@ -26,7 +20,7 @@ describe('Happ presets and helpers', () => {
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toContain('domain:ir');
|
||||
expect(parsed.DirectIp).toContain('geoip:ir');
|
||||
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||
expect(parsed.BlockSites).toEqual([]);
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for china-direct preset', () => {
|
||||
@@ -37,28 +31,92 @@ describe('Happ presets and helpers', () => {
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.Name).toBe('China Direct');
|
||||
expect(parsed.DirectSites).toContain('geosite:cn');
|
||||
expect(parsed.DirectIp).toContain('geoip:cn');
|
||||
expect(parsed).toEqual({
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for adblock preset', () => {
|
||||
const link = buildHappPresetDeeplink('adblock');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
it.each(['iran-bypass', 'china-direct', 'global', 'lan-bypass'])(
|
||||
'adds ad blocking only when opted in for %s without changing its routing',
|
||||
(preset) => {
|
||||
const decode = (link: string) => JSON.parse(atob(link.replace('happ://routing/onadd/', '')));
|
||||
const base = decode(buildHappPresetDeeplink(preset));
|
||||
const withAds = decode(buildHappPresetDeeplink(preset, true));
|
||||
const withoutAds = decode(buildHappPresetDeeplink(preset, false));
|
||||
|
||||
expect(parsed.Name).toBe('AdBlock');
|
||||
expect(parsed.BlockSites).toContain('geosite:category-ads-all');
|
||||
expect(base.BlockSites).toEqual([]);
|
||||
expect(withAds.BlockSites).toEqual(['geosite:category-ads-all']);
|
||||
expect({ ...withAds, BlockSites: [] }).toEqual(base);
|
||||
expect(withoutAds).toEqual(base);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps routing disabled even when ad blocking is selected', () => {
|
||||
expect(buildHappPresetDeeplink('off', true)).toBe('happ://routing/off');
|
||||
});
|
||||
|
||||
it('generates valid base64 payload for global preset', () => {
|
||||
it.each(['adblock', 'unknown'])(
|
||||
'does not generate a profile for unsupported preset %s',
|
||||
(preset) => {
|
||||
expect(buildHappPresetDeeplink(preset)).toBe('');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the global preset without direct exceptions', () => {
|
||||
const link = buildHappPresetDeeplink('global');
|
||||
const b64 = link.replace('happ://routing/onadd/', '');
|
||||
const jsonStr = atob(b64);
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
expect(parsed.Name).toBe('Global Proxy');
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toEqual([]);
|
||||
expect(parsed.DirectIp).toEqual([]);
|
||||
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||
});
|
||||
|
||||
it('generates a separate LAN bypass preset with proxy as the default', () => {
|
||||
const link = buildHappPresetDeeplink('lan-bypass');
|
||||
const prefix = 'happ://routing/onadd/';
|
||||
expect(link.startsWith(prefix)).toBe(true);
|
||||
const parsed = JSON.parse(atob(link.slice(prefix.length)));
|
||||
|
||||
expect(parsed.Name).toBe('Global Bypass Local Network');
|
||||
expect(parsed.GlobalProxy).toBe('true');
|
||||
expect(parsed.DirectSites).toEqual(['geosite:private']);
|
||||
expect(parsed.DirectIp).toEqual(['geoip:private']);
|
||||
expect(parsed.BlockSites).toEqual([]);
|
||||
expect(parsed.DomainStrategy).toBe('AsIs');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildHappRoutingDeeplink,
|
||||
loadHappRouting,
|
||||
parseHappRoutingJson,
|
||||
parseHappRoutingList,
|
||||
} from '@/pages/settings/happRoutingEditor';
|
||||
|
||||
const profile = {
|
||||
Name: '大陆 · فارسی 🛣',
|
||||
GlobalProxy: 'false',
|
||||
RouteOrder: 'direct-proxy-block',
|
||||
DomainStrategy: 'AsIs',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
DnsHosts: { 'dns.alidns.com': '223.5.5.5' },
|
||||
DirectSites: ['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$'],
|
||||
DirectIp: ['geoip:cn'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
FutureSetting: { enabled: true, values: ['保留', 7, null] },
|
||||
};
|
||||
|
||||
describe('Happ routing editor profile loading', () => {
|
||||
it.each(['onadd', 'add'] as const)('loads %s with UTF-8 and preserves every field', (mode) => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
|
||||
expect(loadHappRouting(` happ://routing/${mode}/${encoded}\n`)).toEqual({
|
||||
success: true,
|
||||
profile,
|
||||
mode,
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['base64', 'base64url'] as const)(
|
||||
'accepts padded and unpadded %s payloads',
|
||||
(encoding) => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString(encoding);
|
||||
const unpadded = encoded.replace(/=+$/, '');
|
||||
const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
|
||||
for (const payload of [padded, unpadded]) {
|
||||
const loaded = loadHappRouting(`happ://routing/onadd/${payload}`);
|
||||
expect(loaded).toMatchObject({ success: true, profile });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('loads raw JSON without filling absent fields or discarding extensions', () => {
|
||||
const input = '{"Name":"existing","GlobalProxy":"false","Future":{"nested":[1,null]}}';
|
||||
expect(loadHappRouting(input)).toEqual({
|
||||
success: true,
|
||||
profile: { Name: 'existing', GlobalProxy: 'false', Future: { nested: [1, null] } },
|
||||
mode: 'onadd',
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['json', 'add', 'onadd'] as const)('preserves null lists in a %s profile', (format) => {
|
||||
const nullableProfile = {
|
||||
...profile,
|
||||
DirectSites: null,
|
||||
DirectIp: null,
|
||||
ProxySites: null,
|
||||
ProxyIp: null,
|
||||
BlockSites: null,
|
||||
BlockIp: null,
|
||||
};
|
||||
const json = JSON.stringify(nullableProfile);
|
||||
const source =
|
||||
format === 'json'
|
||||
? json
|
||||
: `happ://routing/${format}/${Buffer.from(json, 'utf8').toString('base64')}`;
|
||||
|
||||
expect(loadHappRouting(source)).toEqual({
|
||||
success: true,
|
||||
profile: nullableProfile,
|
||||
mode: format === 'json' ? 'onadd' : format,
|
||||
isNew: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains the existing new-profile flow only for an empty source', () => {
|
||||
expect(loadHappRouting(' \n ')).toEqual({
|
||||
success: true,
|
||||
profile: {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
},
|
||||
mode: 'onadd',
|
||||
isNew: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['happ://routing/off', 'off'],
|
||||
['https://example.com/rules', 'remote'],
|
||||
[' \nHTTPS://example.com/rules\t ', 'remote'],
|
||||
['http://example.com/rules', 'invalid'],
|
||||
['https://', 'invalid'],
|
||||
['https://example.com/rules with spaces', 'invalid'],
|
||||
['https://example.com/rules\nsecond-line', 'invalid'],
|
||||
['happ://routing/onadd/', 'invalid'],
|
||||
['happ://routing/onadd/%invalid', 'invalid'],
|
||||
['happ://routing/onadd/e3\n0=', 'invalid'],
|
||||
['happ://routing/add//w==', 'invalid'],
|
||||
['happ://routing/onadd/W10=', 'invalid'],
|
||||
['happ://unknown/e30=', 'invalid'],
|
||||
['{"DirectSites":"geosite:cn"}', 'invalid'],
|
||||
['{"DirectIp":[3]}', 'invalid'],
|
||||
['{"Name":', 'invalid'],
|
||||
])('does not replace an unloadable source %s with a new profile', (source, error) => {
|
||||
expect(loadHappRouting(source)).toEqual({ success: false, error });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Happ routing editor JSON and output', () => {
|
||||
it('preserves all fields through a valid JSON edit', () => {
|
||||
expect(parseHappRoutingJson(JSON.stringify(profile))).toEqual(profile);
|
||||
});
|
||||
|
||||
it('preserves extension keys as JSON data without changing object prototypes', () => {
|
||||
const source = '{"Name":"extensions","__proto__":{"clientOption":true},"DirectSites":[]}';
|
||||
const parsed = parseHappRoutingJson(source);
|
||||
expect(parsed).not.toBeNull();
|
||||
if (!parsed) throw new Error('Expected a valid profile');
|
||||
|
||||
const rebuilt = JSON.parse(atob(buildHappRoutingDeeplink(parsed).split('/onadd/')[1]));
|
||||
expect(rebuilt).toEqual(JSON.parse(source));
|
||||
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it.each(['', '{', '[]', 'null', 'true', '42', '{"BlockIp":[null]}', '{"ProxySites":[{}]}'])(
|
||||
'rejects an invalid profile %s',
|
||||
(source) => {
|
||||
expect(parseHappRoutingJson(source)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('generates a standard UTF-8 link while retaining add semantics and unknown fields', () => {
|
||||
const encoded = Buffer.from(JSON.stringify(profile), 'utf8').toString('base64');
|
||||
expect(buildHappRoutingDeeplink(profile, 'add')).toBe(`happ://routing/add/${encoded}`);
|
||||
expect(buildHappRoutingDeeplink(profile)).toBe(`happ://routing/onadd/${encoded}`);
|
||||
});
|
||||
|
||||
it('keeps regexp commas intact when editing one rule per line', () => {
|
||||
expect(
|
||||
parseHappRoutingList(' geosite:cn\r\nregexp:^host[0-9]{1,3}\\.example$\n\n domain:local '),
|
||||
).toEqual(['geosite:cn', 'regexp:^host[0-9]{1,3}\\.example$', 'domain:local']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { EditorView } from 'codemirror';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
import { toBase64Utf8 } from '@/pages/settings/happPresets';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const profile = {
|
||||
Name: '当前草稿',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSDomain: 'https://dns.example/dns-query',
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
DnsHosts: { 'dns.example': '1.1.1.1' },
|
||||
DirectSites: ['geosite:cn', 'regexp:^example{1,2}\\.com$'],
|
||||
ProxySites: ['geosite:google'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
DirectIp: ['geoip:cn'],
|
||||
ProxyIp: ['1.1.1.1/32'],
|
||||
BlockIp: ['0.0.0.0/8'],
|
||||
FutureSetting: { nested: [{ enabled: false, count: 0, text: '保留' }] },
|
||||
};
|
||||
|
||||
const nullableProfile = {
|
||||
...profile,
|
||||
DirectSites: null,
|
||||
DirectIp: null,
|
||||
ProxySites: null,
|
||||
ProxyIp: null,
|
||||
BlockSites: null,
|
||||
BlockIp: null,
|
||||
};
|
||||
|
||||
function routingLink(value: Record<string, unknown>, mode = 'onadd') {
|
||||
return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
|
||||
}
|
||||
|
||||
function renderSettings(input = routingLink(profile)) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
function SettingsHarness() {
|
||||
const [allSetting, setAllSetting] = useState(() =>
|
||||
Object.assign(new AllSetting(), { subRoutingRules: input }),
|
||||
);
|
||||
|
||||
return (
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) => {
|
||||
updateSetting(patch);
|
||||
setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
|
||||
}}
|
||||
isMobile={false}
|
||||
remoteSourceBadge={() => null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visual Rule Generator' }));
|
||||
return within(screen.getByRole('dialog'));
|
||||
}
|
||||
|
||||
function routingRules() {
|
||||
return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function generatedProfile(): Record<string, unknown> {
|
||||
const payload = routingRules().replace(/^happ:\/\/routing\/(?:onadd|add)\//, '');
|
||||
return JSON.parse(
|
||||
new TextDecoder().decode(Uint8Array.from(atob(payload), (char) => char.charCodeAt(0))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function basicField(name: string) {
|
||||
return screen.getByRole('textbox', { name }) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function jsonEditor() {
|
||||
const content = screen.getByRole('textbox', { name: 'JSON editor' });
|
||||
const editor = EditorView.findFromDOM(content);
|
||||
if (!editor) throw new Error('CodeMirror editor not mounted');
|
||||
return editor;
|
||||
}
|
||||
|
||||
function changeJson(value: string) {
|
||||
const editor = jsonEditor();
|
||||
act(() => {
|
||||
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
|
||||
});
|
||||
}
|
||||
|
||||
describe('Happ routing editor', () => {
|
||||
it('loads the current unsaved routing draft when opened', () => {
|
||||
const updateSetting = renderSettings();
|
||||
const latest = { ...profile, DirectSites: ['domain:latest-draft.example'] };
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
|
||||
target: { value: routingLink(latest) },
|
||||
});
|
||||
updateSetting.mockClear();
|
||||
|
||||
const dialog = openEditor();
|
||||
|
||||
expect((dialog.getAllByRole('textbox')[0] as HTMLTextAreaElement).value).toBe(
|
||||
'domain:latest-draft.example',
|
||||
);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['add', 'onadd'])(
|
||||
'preserves other fields and %s semantics when editing basic rules',
|
||||
(mode) => {
|
||||
const updateSetting = renderSettings(routingLink(profile, mode));
|
||||
openEditor();
|
||||
const edited = 'regexp:^other{1,2}\\.com$\n domain:another.example\n';
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), { target: { value: edited } });
|
||||
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe(edited);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(routingRules().startsWith(`happ://routing/${mode}/`)).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
...profile,
|
||||
DirectSites: ['regexp:^other{1,2}\\.com$', 'domain:another.example'],
|
||||
});
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('syncs both tabs while retaining full-profile edits', () => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Blocked Domains (Ad/Malware)'), {
|
||||
target: { value: 'geosite:category-ads-all\ndomain:ads.example' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
const advanced = JSON.parse(jsonEditor().state.doc.toString()) as Record<string, unknown>;
|
||||
expect(advanced).toEqual({
|
||||
...profile,
|
||||
BlockSites: ['geosite:category-ads-all', 'domain:ads.example'],
|
||||
});
|
||||
|
||||
const next = { ...advanced, ProxyIp: ['9.9.9.9/32'], FutureSetting: { deep: ['new'] } };
|
||||
changeJson(JSON.stringify(next, null, 2));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Proxy IPs / CIDRs').value).toBe('9.9.9.9/32');
|
||||
expect(basicField('Blocked Domains (Ad/Malware)').value).toBe(
|
||||
'geosite:category-ads-all\ndomain:ads.example',
|
||||
);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(next);
|
||||
});
|
||||
|
||||
it.each(['Basic rules', 'Advanced editor'])(
|
||||
'preserves untouched null lists when generating from %s',
|
||||
(tab) => {
|
||||
renderSettings(JSON.stringify(nullableProfile));
|
||||
const dialog = openEditor();
|
||||
expect(dialog.queryByRole('alert')).toBeNull();
|
||||
expect(
|
||||
dialog.getAllByRole('textbox').map((field) => (field as HTMLTextAreaElement).value),
|
||||
).toEqual(['', '', '', '', '', '']);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(nullableProfile);
|
||||
fireEvent.click(screen.getByRole('tab', { name: tab }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(generatedProfile()).toEqual(nullableProfile);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('replaces only the edited null list with rules', () => {
|
||||
renderSettings(routingLink(nullableProfile, 'add'));
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'domain:local.example\n' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(routingRules().startsWith('happ://routing/add/')).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
...nullableProfile,
|
||||
DirectSites: ['domain:local.example'],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts setting a list to null in the advanced editor', () => {
|
||||
renderSettings();
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
const next = { ...profile, DirectSites: null };
|
||||
changeJson(JSON.stringify(next));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe('');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
|
||||
expect(generatedProfile()).toEqual(next);
|
||||
});
|
||||
|
||||
it.each(['{"Name":', '{"DirectSites": [42]}'])(
|
||||
'retains invalid JSON %s until repaired',
|
||||
(invalid) => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
changeJson(invalid);
|
||||
|
||||
const generate = screen.getByRole('button', {
|
||||
name: 'Generate Deeplink',
|
||||
}) as HTMLButtonElement;
|
||||
expect(generate.disabled).toBe(true);
|
||||
expect(screen.getByRole('alert').textContent).toContain('arrays of strings');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Advanced editor' }).getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(jsonEditor().state.doc.toString()).toBe(invalid);
|
||||
fireEvent.click(generate);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
|
||||
const repaired = { ...profile, Name: 'Repaired', BlockIp: ['192.0.2.0/24'] };
|
||||
changeJson(JSON.stringify(repaired));
|
||||
expect(generate.disabled).toBe(false);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
|
||||
expect(basicField('Blocked IPs / CIDRs').value).toBe('192.0.2.0/24');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(repaired);
|
||||
},
|
||||
);
|
||||
|
||||
it('discards canceled edits and reloads the latest parent draft on reopening', () => {
|
||||
const updateSetting = renderSettings();
|
||||
openEditor();
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'discard.example' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
changeJson('{invalid');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe(routingLink(profile));
|
||||
|
||||
const latest = { ...profile, DirectSites: ['latest.example'] };
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
|
||||
target: { value: JSON.stringify(latest) },
|
||||
});
|
||||
updateSetting.mockClear();
|
||||
openEditor();
|
||||
expect(screen.getByRole('tab', { name: 'Basic rules' }).getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(basicField('Direct Domains (Bypass)').value).toBe('latest.example');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(latest);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://example.com/DEFAULT.DEEPLINK', 'Remote URLs cannot be loaded here'],
|
||||
['http://example.com/rules', 'not a valid Happ routing link'],
|
||||
['happ://routing/off', 'Routing is disabled'],
|
||||
['happ://routing/onadd/not-base64', 'not a valid Happ routing link'],
|
||||
['{"BlockIp":42}', 'not a valid Happ routing link'],
|
||||
])('blocks generation for unsupported current rules: %s', (input, message) => {
|
||||
const updateSetting = renderSettings(input);
|
||||
openEditor();
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain(message);
|
||||
const generate = screen.getByRole('button', { name: 'Generate Deeplink' }) as HTMLButtonElement;
|
||||
expect(generate.disabled).toBe(true);
|
||||
fireEvent.click(generate);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe(input);
|
||||
});
|
||||
|
||||
it('starts a new custom profile when the current draft is blank', () => {
|
||||
const updateSetting = renderSettings('');
|
||||
openEditor();
|
||||
expect(screen.getByRole('alert').textContent).toContain('No current rules');
|
||||
fireEvent.change(basicField('Direct Domains (Bypass)'), {
|
||||
target: { value: 'domain:local.example\n' },
|
||||
});
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(routingRules().startsWith('happ://routing/onadd/')).toBe(true);
|
||||
expect(generatedProfile()).toEqual({
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['domain:local.example'],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add absent list fields to an unchanged raw JSON profile', () => {
|
||||
const minimal = { Name: 'Minimal', FutureSetting: profile.FutureSetting };
|
||||
renderSettings(JSON.stringify(minimal));
|
||||
openEditor();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
|
||||
expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(minimal);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
|
||||
expect(generatedProfile()).toEqual(minimal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import HappSettingsContent from '@/pages/settings/HappSettingsContent';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const chinaProfile = {
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
};
|
||||
|
||||
function renderSettings(isMobile = false) {
|
||||
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
|
||||
|
||||
function SettingsHarness() {
|
||||
const [allSetting, setAllSetting] = useState(() => {
|
||||
const settings = new AllSetting();
|
||||
settings.subRoutingRules = 'https://example.com/existing-routing';
|
||||
return settings;
|
||||
});
|
||||
|
||||
return (
|
||||
<HappSettingsContent
|
||||
allSetting={allSetting}
|
||||
updateSetting={(patch) => {
|
||||
updateSetting(patch);
|
||||
setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
remoteSourceBadge={() => null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<SettingsHarness />);
|
||||
return updateSetting;
|
||||
}
|
||||
|
||||
function openPresets() {
|
||||
const select = screen.getByRole('combobox').closest('.ant-select');
|
||||
if (!select) throw new Error('Routing preset select not found');
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
return Array.from(
|
||||
document.querySelectorAll(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function choosePreset(label: string) {
|
||||
const option = openPresets().find((item) => item.textContent === label);
|
||||
if (!option) throw new Error(`Routing preset '${label}' not found`);
|
||||
fireEvent.click(option);
|
||||
}
|
||||
|
||||
function routingRules() {
|
||||
return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
|
||||
}
|
||||
|
||||
function routingProfile(): Record<string, unknown> {
|
||||
const prefix = 'happ://routing/onadd/';
|
||||
const link = routingRules();
|
||||
expect(link.startsWith(prefix)).toBe(true);
|
||||
return JSON.parse(atob(link.slice(prefix.length))) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('Happ routing preset controls', () => {
|
||||
it('keeps selecting and toggling local until Apply updates the routing rules', () => {
|
||||
const updateSetting = renderSettings();
|
||||
|
||||
choosePreset('China Direct (Bypass-CN)');
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
expect(includeAdblock.getAttribute('aria-checked')).toBe('false');
|
||||
fireEvent.click(includeAdblock);
|
||||
|
||||
expect(includeAdblock.getAttribute('aria-checked')).toBe('true');
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
expect(routingRules()).toBe('https://example.com/existing-routing');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
|
||||
expect(routingProfile().BlockSites).toEqual(['geosite:category-ads-all']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Iran Bypass', 'Iran Bypass'],
|
||||
['China Direct (Bypass-CN)', 'Bypass-CN'],
|
||||
['Full Proxy', 'Global Proxy'],
|
||||
['Global Bypass Local Network', 'Global Bypass Local Network'],
|
||||
])('applies %s without AdBlock by default', (label, profileName) => {
|
||||
renderSettings();
|
||||
choosePreset(label);
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Include AdBlock' }).getAttribute('aria-checked'),
|
||||
).toBe('false');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({ Name: profileName, BlockSites: [] });
|
||||
});
|
||||
|
||||
it('keeps LAN bypass separate from Full Proxy when switching presets', () => {
|
||||
renderSettings();
|
||||
choosePreset('Global Bypass Local Network');
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Include AdBlock' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({
|
||||
Name: 'Global Bypass Local Network',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:private'],
|
||||
DirectIp: ['geoip:private'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
|
||||
choosePreset('Full Proxy');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toMatchObject({
|
||||
Name: 'Global Proxy',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves China routing when opting in and clears AdBlock when reapplied after opting out', () => {
|
||||
const updateSetting = renderSettings(true);
|
||||
choosePreset('China Direct (Bypass-CN)');
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
fireEvent.click(includeAdblock);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(routingProfile()).toEqual({
|
||||
...chinaProfile,
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
});
|
||||
const withAdblock = routingRules();
|
||||
|
||||
fireEvent.click(includeAdblock);
|
||||
expect(routingRules()).toBe(withAdblock);
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledTimes(2);
|
||||
expect(routingProfile()).toEqual(chinaProfile);
|
||||
});
|
||||
|
||||
it('offers only base presets so AdBlock cannot replace a country preset', () => {
|
||||
renderSettings();
|
||||
|
||||
expect(openPresets().map((option) => option.textContent)).toEqual([
|
||||
'Iran Bypass',
|
||||
'China Direct (Bypass-CN)',
|
||||
'Full Proxy',
|
||||
'Global Bypass Local Network',
|
||||
'Disable Routing (happ://routing/off)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables AdBlock for Off and applies the off link even after opting in', () => {
|
||||
const updateSetting = renderSettings();
|
||||
const includeAdblock = screen.getByRole('switch', { name: 'Include AdBlock' });
|
||||
fireEvent.click(includeAdblock);
|
||||
choosePreset('Disable Routing (happ://routing/off)');
|
||||
|
||||
expect((includeAdblock as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply preset' }));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledExactlyOnceWith({
|
||||
subRoutingRules: 'happ://routing/off',
|
||||
});
|
||||
expect(routingRules()).toBe('happ://routing/off');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user