| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- 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);
- });
- });
|