happ-routing-editor.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import { useState } from 'react';
  2. import { describe, expect, it, vi } from 'vitest';
  3. import { act, fireEvent, screen, within } from '@testing-library/react';
  4. import { EditorView } from 'codemirror';
  5. import { AllSetting } from '@/models/setting';
  6. import HappSettingsContent from '@/pages/settings/HappSettingsContent';
  7. import { toBase64Utf8 } from '@/pages/settings/happPresets';
  8. import { renderWithProviders } from './test-utils';
  9. const profile = {
  10. Name: '当前草稿',
  11. GlobalProxy: 'true',
  12. RouteOrder: 'block-proxy-direct',
  13. RemoteDNSDomain: 'https://dns.example/dns-query',
  14. DomainStrategy: 'IPIfNonMatch',
  15. DnsHosts: { 'dns.example': '1.1.1.1' },
  16. DirectSites: ['geosite:cn', 'regexp:^example{1,2}\\.com$'],
  17. ProxySites: ['geosite:google'],
  18. BlockSites: ['geosite:category-ads-all'],
  19. DirectIp: ['geoip:cn'],
  20. ProxyIp: ['1.1.1.1/32'],
  21. BlockIp: ['0.0.0.0/8'],
  22. FutureSetting: { nested: [{ enabled: false, count: 0, text: '保留' }] },
  23. };
  24. const nullableProfile = {
  25. ...profile,
  26. DirectSites: null,
  27. DirectIp: null,
  28. ProxySites: null,
  29. ProxyIp: null,
  30. BlockSites: null,
  31. BlockIp: null,
  32. };
  33. function routingLink(value: Record<string, unknown>, mode = 'onadd') {
  34. return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
  35. }
  36. function renderSettings(input = routingLink(profile)) {
  37. const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
  38. function SettingsHarness() {
  39. const [allSetting, setAllSetting] = useState(() =>
  40. Object.assign(new AllSetting(), { subRoutingRules: input }),
  41. );
  42. return (
  43. <HappSettingsContent
  44. allSetting={allSetting}
  45. updateSetting={(patch) => {
  46. updateSetting(patch);
  47. setAllSetting((previous) => Object.assign(new AllSetting(), previous, patch));
  48. }}
  49. isMobile={false}
  50. remoteSourceBadge={() => null}
  51. />
  52. );
  53. }
  54. renderWithProviders(<SettingsHarness />);
  55. return updateSetting;
  56. }
  57. function openEditor() {
  58. fireEvent.click(screen.getByRole('button', { name: 'Visual Rule Generator' }));
  59. return within(screen.getByRole('dialog'));
  60. }
  61. function routingRules() {
  62. return (screen.getByRole('textbox', { name: 'Routing rules' }) as HTMLTextAreaElement).value;
  63. }
  64. function generatedProfile(): Record<string, unknown> {
  65. const payload = routingRules().replace(/^happ:\/\/routing\/(?:onadd|add)\//, '');
  66. return JSON.parse(
  67. new TextDecoder().decode(Uint8Array.from(atob(payload), (char) => char.charCodeAt(0))),
  68. ) as Record<string, unknown>;
  69. }
  70. function basicField(name: string) {
  71. return screen.getByRole('textbox', { name }) as HTMLTextAreaElement;
  72. }
  73. function jsonEditor() {
  74. const content = screen.getByRole('textbox', { name: 'JSON editor' });
  75. const editor = EditorView.findFromDOM(content);
  76. if (!editor) throw new Error('CodeMirror editor not mounted');
  77. return editor;
  78. }
  79. function changeJson(value: string) {
  80. const editor = jsonEditor();
  81. act(() => {
  82. editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
  83. });
  84. }
  85. describe('Happ routing editor', () => {
  86. it('loads the current unsaved routing draft when opened', () => {
  87. const updateSetting = renderSettings();
  88. const latest = { ...profile, DirectSites: ['domain:latest-draft.example'] };
  89. fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
  90. target: { value: routingLink(latest) },
  91. });
  92. updateSetting.mockClear();
  93. const dialog = openEditor();
  94. expect((dialog.getAllByRole('textbox')[0] as HTMLTextAreaElement).value).toBe(
  95. 'domain:latest-draft.example',
  96. );
  97. expect(updateSetting).not.toHaveBeenCalled();
  98. });
  99. it.each(['add', 'onadd'])(
  100. 'preserves other fields and %s semantics when editing basic rules',
  101. (mode) => {
  102. const updateSetting = renderSettings(routingLink(profile, mode));
  103. openEditor();
  104. const edited = 'regexp:^other{1,2}\\.com$\n domain:another.example\n';
  105. fireEvent.change(basicField('Direct Domains (Bypass)'), { target: { value: edited } });
  106. expect(basicField('Direct Domains (Bypass)').value).toBe(edited);
  107. expect(updateSetting).not.toHaveBeenCalled();
  108. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  109. expect(routingRules().startsWith(`happ://routing/${mode}/`)).toBe(true);
  110. expect(generatedProfile()).toEqual({
  111. ...profile,
  112. DirectSites: ['regexp:^other{1,2}\\.com$', 'domain:another.example'],
  113. });
  114. expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subRoutingRules: routingRules() });
  115. expect(screen.queryByRole('dialog')).toBeNull();
  116. },
  117. );
  118. it('syncs both tabs while retaining full-profile edits', () => {
  119. const updateSetting = renderSettings();
  120. openEditor();
  121. fireEvent.change(basicField('Blocked Domains (Ad/Malware)'), {
  122. target: { value: 'geosite:category-ads-all\ndomain:ads.example' },
  123. });
  124. fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
  125. const advanced = JSON.parse(jsonEditor().state.doc.toString()) as Record<string, unknown>;
  126. expect(advanced).toEqual({
  127. ...profile,
  128. BlockSites: ['geosite:category-ads-all', 'domain:ads.example'],
  129. });
  130. const next = { ...advanced, ProxyIp: ['9.9.9.9/32'], FutureSetting: { deep: ['new'] } };
  131. changeJson(JSON.stringify(next, null, 2));
  132. fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
  133. expect(basicField('Proxy IPs / CIDRs').value).toBe('9.9.9.9/32');
  134. expect(basicField('Blocked Domains (Ad/Malware)').value).toBe(
  135. 'geosite:category-ads-all\ndomain:ads.example',
  136. );
  137. expect(updateSetting).not.toHaveBeenCalled();
  138. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  139. expect(generatedProfile()).toEqual(next);
  140. });
  141. it.each(['Basic rules', 'Advanced editor'])(
  142. 'preserves untouched null lists when generating from %s',
  143. (tab) => {
  144. renderSettings(JSON.stringify(nullableProfile));
  145. const dialog = openEditor();
  146. expect(dialog.queryByRole('alert')).toBeNull();
  147. expect(
  148. dialog.getAllByRole('textbox').map((field) => (field as HTMLTextAreaElement).value),
  149. ).toEqual(['', '', '', '', '', '']);
  150. fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
  151. expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(nullableProfile);
  152. fireEvent.click(screen.getByRole('tab', { name: tab }));
  153. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  154. expect(generatedProfile()).toEqual(nullableProfile);
  155. expect(screen.queryByRole('dialog')).toBeNull();
  156. },
  157. );
  158. it('replaces only the edited null list with rules', () => {
  159. renderSettings(routingLink(nullableProfile, 'add'));
  160. openEditor();
  161. fireEvent.change(basicField('Direct Domains (Bypass)'), {
  162. target: { value: 'domain:local.example\n' },
  163. });
  164. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  165. expect(routingRules().startsWith('happ://routing/add/')).toBe(true);
  166. expect(generatedProfile()).toEqual({
  167. ...nullableProfile,
  168. DirectSites: ['domain:local.example'],
  169. });
  170. });
  171. it('accepts setting a list to null in the advanced editor', () => {
  172. renderSettings();
  173. openEditor();
  174. fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
  175. const next = { ...profile, DirectSites: null };
  176. changeJson(JSON.stringify(next));
  177. fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
  178. expect(basicField('Direct Domains (Bypass)').value).toBe('');
  179. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  180. expect(generatedProfile()).toEqual(next);
  181. });
  182. it.each(['{"Name":', '{"DirectSites": [42]}'])(
  183. 'retains invalid JSON %s until repaired',
  184. (invalid) => {
  185. const updateSetting = renderSettings();
  186. openEditor();
  187. fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
  188. changeJson(invalid);
  189. const generate = screen.getByRole('button', {
  190. name: 'Generate Deeplink',
  191. }) as HTMLButtonElement;
  192. expect(generate.disabled).toBe(true);
  193. expect(screen.getByRole('alert').textContent).toContain('arrays of strings');
  194. fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
  195. expect(
  196. screen.getByRole('tab', { name: 'Advanced editor' }).getAttribute('aria-selected'),
  197. ).toBe('true');
  198. expect(jsonEditor().state.doc.toString()).toBe(invalid);
  199. fireEvent.click(generate);
  200. expect(updateSetting).not.toHaveBeenCalled();
  201. const repaired = { ...profile, Name: 'Repaired', BlockIp: ['192.0.2.0/24'] };
  202. changeJson(JSON.stringify(repaired));
  203. expect(generate.disabled).toBe(false);
  204. fireEvent.click(screen.getByRole('tab', { name: 'Basic rules' }));
  205. expect(basicField('Blocked IPs / CIDRs').value).toBe('192.0.2.0/24');
  206. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  207. expect(generatedProfile()).toEqual(repaired);
  208. },
  209. );
  210. it('discards canceled edits and reloads the latest parent draft on reopening', () => {
  211. const updateSetting = renderSettings();
  212. openEditor();
  213. fireEvent.change(basicField('Direct Domains (Bypass)'), {
  214. target: { value: 'discard.example' },
  215. });
  216. fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
  217. changeJson('{invalid');
  218. fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
  219. expect(updateSetting).not.toHaveBeenCalled();
  220. expect(routingRules()).toBe(routingLink(profile));
  221. const latest = { ...profile, DirectSites: ['latest.example'] };
  222. fireEvent.change(screen.getByRole('textbox', { name: 'Routing rules' }), {
  223. target: { value: JSON.stringify(latest) },
  224. });
  225. updateSetting.mockClear();
  226. openEditor();
  227. expect(screen.getByRole('tab', { name: 'Basic rules' }).getAttribute('aria-selected')).toBe(
  228. 'true',
  229. );
  230. expect(basicField('Direct Domains (Bypass)').value).toBe('latest.example');
  231. expect(screen.queryByRole('alert')).toBeNull();
  232. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  233. expect(generatedProfile()).toEqual(latest);
  234. expect(updateSetting).toHaveBeenCalledTimes(1);
  235. });
  236. it.each([
  237. ['https://example.com/DEFAULT.DEEPLINK', 'Remote URLs cannot be loaded here'],
  238. ['http://example.com/rules', 'not a valid Happ routing link'],
  239. ['happ://routing/off', 'Routing is disabled'],
  240. ['happ://routing/onadd/not-base64', 'not a valid Happ routing link'],
  241. ['{"BlockIp":42}', 'not a valid Happ routing link'],
  242. ])('blocks generation for unsupported current rules: %s', (input, message) => {
  243. const updateSetting = renderSettings(input);
  244. openEditor();
  245. expect(screen.getByRole('alert').textContent).toContain(message);
  246. const generate = screen.getByRole('button', { name: 'Generate Deeplink' }) as HTMLButtonElement;
  247. expect(generate.disabled).toBe(true);
  248. fireEvent.click(generate);
  249. expect(updateSetting).not.toHaveBeenCalled();
  250. expect(routingRules()).toBe(input);
  251. });
  252. it('starts a new custom profile when the current draft is blank', () => {
  253. const updateSetting = renderSettings('');
  254. openEditor();
  255. expect(screen.getByRole('alert').textContent).toContain('No current rules');
  256. fireEvent.change(basicField('Direct Domains (Bypass)'), {
  257. target: { value: 'domain:local.example\n' },
  258. });
  259. expect(updateSetting).not.toHaveBeenCalled();
  260. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  261. expect(routingRules().startsWith('happ://routing/onadd/')).toBe(true);
  262. expect(generatedProfile()).toEqual({
  263. Name: 'Custom Rules',
  264. GlobalProxy: 'true',
  265. DirectSites: ['domain:local.example'],
  266. DirectIp: [],
  267. ProxySites: [],
  268. ProxyIp: [],
  269. BlockSites: [],
  270. BlockIp: [],
  271. DomainStrategy: 'IPIfNonMatch',
  272. });
  273. });
  274. it('does not add absent list fields to an unchanged raw JSON profile', () => {
  275. const minimal = { Name: 'Minimal', FutureSetting: profile.FutureSetting };
  276. renderSettings(JSON.stringify(minimal));
  277. openEditor();
  278. fireEvent.click(screen.getByRole('tab', { name: 'Advanced editor' }));
  279. expect(JSON.parse(jsonEditor().state.doc.toString())).toEqual(minimal);
  280. fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
  281. expect(generatedProfile()).toEqual(minimal);
  282. });
  283. });