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:
NgaiYeanCoi
2026-09-27 04:57:01 +08:00
committed by GitHub
parent 94fa317e76
commit 5e15120cec
22 changed files with 1363 additions and 209 deletions
@@ -0,0 +1,204 @@
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Space, Tabs, Typography } from 'antd';
import JsonEditor from '@/components/form/JsonEditor';
import type { HappRoutingProfile } from '@/schemas/happRouting';
import {
buildHappRoutingDeeplink,
loadHappRouting,
parseHappRoutingJson,
parseHappRoutingList,
type HappRoutingListKey,
} from './happRoutingEditor';
interface HappRoutingEditorModalProps {
input: string;
onCancel: () => void;
onGenerate: (deeplink: string) => void;
}
const basicFields: { key: HappRoutingListKey; label: string; placeholder: string }[] = [
{
key: 'DirectSites',
label: 'subHappDirectDomains',
placeholder: 'domain:ir\ndomain:cn\nexample.local',
},
{
key: 'ProxySites',
label: 'subHappProxyDomains',
placeholder: 'geosite:google\nyoutube.com',
},
{
key: 'BlockSites',
label: 'subHappBlockDomains',
placeholder: 'geosite:category-ads-all\nanalytics.google.com',
},
{
key: 'DirectIp',
label: 'subHappDirectIPs',
placeholder: 'geoip:ir\n192.168.0.0/16\n10.0.0.0/8',
},
{
key: 'ProxyIp',
label: 'subHappProxyIPs',
placeholder: '1.1.1.1/32\n8.8.8.8/32',
},
{
key: 'BlockIp',
label: 'subHappBlockIPs',
placeholder: 'geoip:phishing\n0.0.0.0/8',
},
];
type BasicBuffers = Record<HappRoutingListKey, string>;
function basicBuffers(profile: HappRoutingProfile | null): BasicBuffers {
return {
DirectSites: profile?.DirectSites?.join('\n') ?? '',
ProxySites: profile?.ProxySites?.join('\n') ?? '',
BlockSites: profile?.BlockSites?.join('\n') ?? '',
DirectIp: profile?.DirectIp?.join('\n') ?? '',
ProxyIp: profile?.ProxyIp?.join('\n') ?? '',
BlockIp: profile?.BlockIp?.join('\n') ?? '',
};
}
function mergeBasicRules(profile: HappRoutingProfile, buffers: BasicBuffers): HappRoutingProfile {
const next = { ...profile };
// Only replace edited lists; absent lists and all other profile fields must survive unchanged.
for (const { key } of basicFields) {
if (buffers[key] !== (profile[key]?.join('\n') ?? '')) {
next[key] = parseHappRoutingList(buffers[key]);
}
}
return next;
}
const loadErrorKeys = {
off: 'subHappEditorLoadOff',
remote: 'subHappEditorLoadRemote',
invalid: 'subHappEditorLoadInvalid',
};
export default function HappRoutingEditorModal({
input,
onCancel,
onGenerate,
}: HappRoutingEditorModalProps) {
const { t } = useTranslation();
const fieldId = useId();
const [loaded] = useState(() => loadHappRouting(input));
const [profile, setProfile] = useState(() => (loaded.success ? loaded.profile : null));
const [activeTab, setActiveTab] = useState('basic');
// Keep raw buffers while typing so trailing newlines and temporarily invalid JSON are not lost.
const [buffers, setBuffers] = useState(() => basicBuffers(profile));
const [jsonText, setJsonText] = useState(() => (profile ? JSON.stringify(profile, null, 2) : ''));
const advancedProfile = activeTab === 'advanced' ? parseHappRoutingJson(jsonText) : null;
const invalidJson = activeTab === 'advanced' && advancedProfile === null;
const switchTab = (nextTab: string) => {
if (!profile || nextTab === activeTab) return;
if (nextTab === 'advanced') {
const next = mergeBasicRules(profile, buffers);
setProfile(next);
setJsonText(JSON.stringify(next, null, 2));
} else {
if (!advancedProfile) return;
setProfile(advancedProfile);
setBuffers(basicBuffers(advancedProfile));
}
setActiveTab(nextTab);
};
const generate = () => {
if (!loaded.success || !profile) return;
const next = activeTab === 'advanced' ? advancedProfile : mergeBasicRules(profile, buffers);
if (next) onGenerate(buildHappRoutingDeeplink(next, loaded.mode));
};
return (
<Modal
title={t('pages.settings.subHappModalTitle')}
open
onCancel={onCancel}
onOk={generate}
okText={t('pages.settings.subHappBuildDeeplink')}
okButtonProps={{ disabled: !loaded.success || invalidJson }}
width={650}
>
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
{!loaded.success ? (
<Alert type="error" showIcon title={t(`pages.settings.${loadErrorKeys[loaded.error]}`)} />
) : loaded.isNew ? (
<Alert type="info" showIcon title={t('pages.settings.subHappEditorNew')} />
) : null}
{loaded.success ? (
<Tabs
activeKey={activeTab}
onChange={switchTab}
destroyOnHidden
items={[
{
key: 'basic',
label: t('pages.settings.subHappEditorBasic'),
disabled: invalidJson,
children: (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary" id={`${fieldId}-hint`}>
{t('pages.settings.subHappEditorListHint')}
</Typography.Text>
{basicFields.map(({ key, label, placeholder }) => (
<div key={key}>
<label
htmlFor={`${fieldId}-${key}`}
style={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
>
{t(`pages.settings.${label}`)}
</label>
<Input.TextArea
id={`${fieldId}-${key}`}
aria-describedby={`${fieldId}-hint`}
rows={2}
value={buffers[key]}
placeholder={placeholder}
onChange={(event) =>
setBuffers((previous) => ({ ...previous, [key]: event.target.value }))
}
/>
</div>
))}
</Space>
),
},
{
key: 'advanced',
label: t('pages.settings.subHappEditorAdvanced'),
children: (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{t('pages.settings.subHappEditorAdvancedHint')}
</Typography.Text>
{invalidJson ? (
<Alert
type="error"
showIcon
title={t('pages.settings.subHappEditorInvalidJson')}
/>
) : null}
<JsonEditor
value={jsonText}
onChange={setJsonText}
minHeight="320px"
maxHeight="50vh"
/>
</Space>
),
},
]}
/>
) : null}
</Space>
</Modal>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd';
import { Button, Input, Select, Space, Switch, Tabs, message } from 'antd';
import {
BranchesOutlined,
BuildOutlined,
@@ -13,7 +13,8 @@ import {
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
import { buildHappPresetDeeplink } from './happPresets';
import HappRoutingEditorModal from './HappRoutingEditorModal';
import { catTabLabel } from './catTabLabel';
interface HappSettingsContentProps {
@@ -32,38 +33,20 @@ export default function HappSettingsContent({
defaultActiveTab = 'routing',
}: HappSettingsContentProps) {
const { t } = useTranslation();
// Generator choices stay local until Apply updates the draft; page Save persists it.
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
const [includeAdblock, setIncludeAdblock] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [directDomains, setDirectDomains] = useState('');
const [proxyDomains, setProxyDomains] = useState('');
const [blockDomains, setBlockDomains] = useState('');
const [directIPs, setDirectIPs] = useState('');
const [proxyIPs, setProxyIPs] = useState('');
const [blockIPs, setBlockIPs] = useState('');
const applyPreset = () => {
const payload = buildHappPresetDeeplink(selectedPreset);
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
if (payload) {
updateSetting({ subRoutingRules: payload });
message.success(t('pages.settings.subHappPresetApplied'));
}
};
const handleBuildDeeplink = () => {
const profile = {
Name: 'Custom Rules',
GlobalProxy: 'true',
DirectSites: parseList(directDomains),
DirectIp: parseList(directIPs),
ProxySites: parseList(proxyDomains),
ProxyIp: parseList(proxyIPs),
BlockSites: parseList(blockDomains),
BlockIp: parseList(blockIPs),
DomainStrategy: 'IPIfNonMatch',
};
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
const handleBuildDeeplink = (deeplink: string) => {
updateSetting({ subRoutingRules: deeplink });
setIsModalOpen(false);
message.success(t('pages.settings.subHappDeeplinkGenerated'));
@@ -112,21 +95,31 @@ export default function HappSettingsContent({
title={t('pages.settings.subHappPresets')}
description={t('pages.settings.subHappPresetsDesc')}
>
<Space orientation="horizontal" style={{ width: '100%' }}>
<Space orientation="horizontal" wrap style={{ width: '100%' }}>
<Select
aria-label={t('pages.settings.subHappPresets')}
value={selectedPreset}
style={{ minWidth: 170 }}
onChange={setSelectedPreset}
options={[
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
{ value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') },
{ value: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
{ value: 'off', label: t('pages.settings.subHappPresetOff') },
]}
/>
<Space size="small">
<Switch
aria-label={t('pages.settings.subHappIncludeAdblock')}
checked={includeAdblock}
disabled={selectedPreset === 'off'}
onChange={setIncludeAdblock}
/>
<span>{t('pages.settings.subHappIncludeAdblock')}</span>
</Space>
<Button type="primary" onClick={applyPreset}>
{t('pages.settings.subHappPresets')}
{t('pages.settings.subHappApplyPreset')}
</Button>
</Space>
</SettingListItem>
@@ -589,83 +582,14 @@ export default function HappSettingsContent({
]}
/>
<Modal
title={t('pages.settings.subHappModalTitle')}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
onOk={handleBuildDeeplink}
okText={t('pages.settings.subHappBuildDeeplink')}
width={650}
>
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappDirectDomains')}
</div>
<Input.TextArea
rows={2}
value={directDomains}
placeholder="domain:ir, domain:cn, example.local"
onChange={(e) => setDirectDomains(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappProxyDomains')}
</div>
<Input.TextArea
rows={2}
value={proxyDomains}
placeholder="geosite:google, youtube.com"
onChange={(e) => setProxyDomains(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappBlockDomains')}
</div>
<Input.TextArea
rows={2}
value={blockDomains}
placeholder="geosite:category-ads-all, analytics.google.com"
onChange={(e) => setBlockDomains(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappDirectIPs')}
</div>
<Input.TextArea
rows={2}
value={directIPs}
placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
onChange={(e) => setDirectIPs(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappProxyIPs')}
</div>
<Input.TextArea
rows={2}
value={proxyIPs}
placeholder="1.1.1.1/32, 8.8.8.8/32"
onChange={(e) => setProxyIPs(e.target.value)}
/>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.settings.subHappBlockIPs')}
</div>
<Input.TextArea
rows={2}
value={blockIPs}
placeholder="geoip:phishing, 0.0.0.0/8"
onChange={(e) => setBlockIPs(e.target.value)}
/>
</div>
</Space>
</Modal>
{/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
{isModalOpen ? (
<HappRoutingEditorModal
input={allSetting.subRoutingRules}
onCancel={() => setIsModalOpen(false)}
onGenerate={handleBuildDeeplink}
/>
) : null}
</>
);
}
+64 -34
View File
@@ -7,16 +7,11 @@ export function toBase64Utf8(str: string): string {
);
}
// Splits multiline or comma-separated string into clean unique token arrays.
export function parseList(input: string): string[] {
return input
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
// Build standard Happ routing deeplink or special state for curated presets.
export function buildHappPresetDeeplink(preset: string): string {
export function buildHappPresetDeeplink(preset: string, includeAdblock = false): string {
// Ad blocking is opt-in for each generated profile, independent of the base routing rules.
const blockSites = includeAdblock ? ['geosite:category-ads-all'] : [];
switch (preset) {
case 'off':
return 'happ://routing/off';
@@ -27,9 +22,20 @@ export function buildHappPresetDeeplink(preset: string): string {
JSON.stringify({
Name: 'Iran Bypass',
GlobalProxy: 'true',
DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
BlockSites: ['geosite:category-ads-all'],
RouteOrder: 'block-proxy-direct',
DirectSites: ['geosite:private', 'domain:ir', 'geosite:category-ir'],
DirectIp: [
'geoip:ir',
'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',
],
BlockSites: blockSites,
BlockIp: [],
ProxySites: [],
ProxyIp: [],
@@ -42,32 +48,38 @@ export function buildHappPresetDeeplink(preset: string): string {
'happ://routing/onadd/' +
toBase64Utf8(
JSON.stringify({
Name: 'China Direct',
Name: 'Bypass-CN',
GlobalProxy: 'true',
DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
BlockSites: ['geosite:category-ads-all'],
BlockIp: [],
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: [],
DomainStrategy: 'IPIfNonMatch',
}),
)
);
case 'adblock':
return (
'happ://routing/onadd/' +
toBase64Utf8(
JSON.stringify({
Name: 'AdBlock',
GlobalProxy: 'true',
DirectSites: [],
DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
BlockSites: ['geosite:category-ads-all'],
BlockSites: blockSites,
BlockIp: [],
ProxySites: [],
ProxyIp: [],
DomainStrategy: 'IPIfNonMatch',
FakeDNS: 'false',
UseChunkFiles: 'true',
}),
)
);
@@ -80,7 +92,25 @@ export function buildHappPresetDeeplink(preset: string): string {
GlobalProxy: 'true',
DirectSites: [],
DirectIp: [],
BlockSites: [],
BlockSites: blockSites,
BlockIp: [],
ProxySites: [],
ProxyIp: [],
DomainStrategy: 'AsIs',
}),
)
);
// LAN bypass has its own identity so applying it does not overwrite the Global profile.
case 'lan-bypass':
return (
'happ://routing/onadd/' +
toBase64Utf8(
JSON.stringify({
Name: 'Global Bypass Local Network',
GlobalProxy: 'true',
DirectSites: ['geosite:private'],
DirectIp: ['geoip:private'],
BlockSites: blockSites,
BlockIp: [],
ProxySites: [],
ProxyIp: [],
@@ -0,0 +1,90 @@
import { HappRoutingProfileSchema, type HappRoutingProfile } from '@/schemas/happRouting';
import { toBase64Utf8 } from './happPresets';
import { isRemoteRoutingSource } from './subscriptionShared';
export type HappRoutingMode = 'add' | 'onadd';
export type HappRoutingListKey =
| 'DirectSites'
| 'DirectIp'
| 'ProxySites'
| 'ProxyIp'
| 'BlockSites'
| 'BlockIp';
export type HappRoutingLoadResult =
| { success: true; profile: HappRoutingProfile; mode: HappRoutingMode; isNew: boolean }
| { success: false; error: 'off' | 'remote' | 'invalid' };
export function parseHappRoutingJson(input: string): HappRoutingProfile | null {
try {
const value: unknown = JSON.parse(input);
const result = HappRoutingProfileSchema.safeParse(value);
// Keep the validated input object; schema output can discard some unknown extension keys.
return result.success ? (value as HappRoutingProfile) : null;
} catch {
return null;
}
}
export function loadHappRouting(input: string): HappRoutingLoadResult {
const source = input.trim();
if (!source) {
return {
success: true,
profile: {
Name: 'Custom Rules',
GlobalProxy: 'true',
DirectSites: [],
DirectIp: [],
ProxySites: [],
ProxyIp: [],
BlockSites: [],
BlockIp: [],
DomainStrategy: 'IPIfNonMatch',
},
mode: 'onadd',
isNew: true,
};
}
if (source === 'happ://routing/off') return { success: false, error: 'off' };
if (isRemoteRoutingSource(source)) return { success: false, error: 'remote' };
if (source.startsWith('{')) {
const profile = parseHappRoutingJson(source);
return profile
? { success: true, profile, mode: 'onadd', isNew: false }
: { success: false, error: 'invalid' };
}
const match = /^happ:\/\/routing\/(onadd|add)\/([A-Za-z0-9+/_-]+={0,2})$/.exec(source);
if (!match) return { success: false, error: 'invalid' };
try {
const binary = atob(match[2].replace(/-/g, '+').replace(/_/g, '/'));
// Decode UTF-8 strictly so malformed bytes cannot silently change profile names or rules.
const json = new TextDecoder('utf-8', { fatal: true }).decode(
Uint8Array.from(binary, (character) => character.charCodeAt(0)),
);
const profile = parseHappRoutingJson(json);
return profile
? { success: true, profile, mode: match[1] as HappRoutingMode, isNew: false }
: { success: false, error: 'invalid' };
} catch {
return { success: false, error: 'invalid' };
}
}
export function buildHappRoutingDeeplink(
profile: HappRoutingProfile,
mode: HappRoutingMode = 'onadd',
): string {
return `happ://routing/${mode}/` + toBase64Utf8(JSON.stringify(profile));
}
export function parseHappRoutingList(input: string): string[] {
// Commas can belong to regexp rules, so the editor uses one rule per line.
return input
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter(Boolean);
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod';
// Extensions belong to the Happ client and must survive a trip through this editor.
export const HappRoutingProfileSchema = z
.object({
// The backend treats null lists as absent; preserve them until explicitly edited.
DirectSites: z.array(z.string()).nullish(),
DirectIp: z.array(z.string()).nullish(),
ProxySites: z.array(z.string()).nullish(),
ProxyIp: z.array(z.string()).nullish(),
BlockSites: z.array(z.string()).nullish(),
BlockIp: z.array(z.string()).nullish(),
})
.catchall(z.unknown());
export type HappRoutingProfile = z.infer<typeof HappRoutingProfileSchema>;
+77 -19
View File
@@ -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');
});
});