| 1 | import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 2 | |
| 3 | type MockDispatcher = { |
| 4 | kind: string |
| 5 | url: string |
| 6 | close: ReturnType<typeof vi.fn> |
| 7 | } |
| 8 | |
| 9 | const proxyTestState = vi.hoisted(() => { |
| 10 | const createDispatcher = (kind: string, url = ''): MockDispatcher => ({ |
| 11 | kind, |
| 12 | url, |
| 13 | close: vi.fn().mockResolvedValue(undefined) |
| 14 | }) |
| 15 | |
| 16 | const originalDispatcher = createDispatcher('original') |
| 17 | const createdDispatchers: MockDispatcher[] = [] |
| 18 | const logMock = { |
| 19 | info: vi.fn() |
| 20 | } |
| 21 | const getGlobalDispatcherMock = vi.fn(() => originalDispatcher) |
| 22 | const setGlobalDispatcherMock = vi.fn() |
| 23 | const ProxyAgentMock = vi.fn(function (url: string) { |
| 24 | const dispatcher = createDispatcher('proxy', url) |
| 25 | createdDispatchers.push(dispatcher) |
| 26 | return dispatcher |
| 27 | }) |
| 28 | const Socks5ProxyAgentMock = vi.fn(function (url: string) { |
| 29 | const dispatcher = createDispatcher('socks', url) |
| 30 | createdDispatchers.push(dispatcher) |
| 31 | return dispatcher |
| 32 | }) |
| 33 | |
| 34 | return { |
| 35 | createdDispatchers, |
| 36 | getGlobalDispatcherMock, |
| 37 | logMock, |
| 38 | originalDispatcher, |
| 39 | ProxyAgentMock, |
| 40 | setGlobalDispatcherMock, |
| 41 | Socks5ProxyAgentMock |
| 42 | } |
| 43 | }) |
| 44 | |
| 45 | vi.mock('electron-log/main.js', () => ({ |
| 46 | default: proxyTestState.logMock |
| 47 | })) |
| 48 | |
| 49 | vi.mock('undici', () => ({ |
| 50 | ProxyAgent: proxyTestState.ProxyAgentMock, |
| 51 | Socks5ProxyAgent: proxyTestState.Socks5ProxyAgentMock, |
| 52 | getGlobalDispatcher: proxyTestState.getGlobalDispatcherMock, |
| 53 | setGlobalDispatcher: proxyTestState.setGlobalDispatcherMock |
| 54 | })) |
| 55 | |
| 56 | async function loadProxyModule() { |
| 57 | vi.resetModules() |
| 58 | return import('../../../src/main/utils/proxy') |
| 59 | } |
| 60 | |
| 61 | describe('proxy dispatcher management', () => { |
| 62 | beforeEach(() => { |
| 63 | proxyTestState.createdDispatchers.length = 0 |
| 64 | proxyTestState.getGlobalDispatcherMock.mockClear() |
| 65 | proxyTestState.getGlobalDispatcherMock.mockReturnValue(proxyTestState.originalDispatcher) |
| 66 | proxyTestState.logMock.info.mockClear() |
| 67 | proxyTestState.originalDispatcher.close.mockClear() |
| 68 | proxyTestState.ProxyAgentMock.mockClear() |
| 69 | proxyTestState.setGlobalDispatcherMock.mockClear() |
| 70 | proxyTestState.Socks5ProxyAgentMock.mockClear() |
| 71 | }) |
| 72 | |
| 73 | it('applies HTTP proxy via ProxyAgent and trims the URL', async () => { |
| 74 | const { applyProxy } = await loadProxyModule() |
| 75 | |
| 76 | applyProxy(' http://127.0.0.1:7890 ') |
| 77 | |
| 78 | expect(proxyTestState.ProxyAgentMock).toHaveBeenCalledWith('http://127.0.0.1:7890') |
| 79 | expect(proxyTestState.Socks5ProxyAgentMock).not.toHaveBeenCalled() |
| 80 | expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenCalledWith( |
| 81 | proxyTestState.createdDispatchers[0] |
| 82 | ) |
| 83 | expect(proxyTestState.logMock.info).toHaveBeenCalledWith( |
| 84 | '[proxy] applied', |
| 85 | 'http://127.0.0.1:7890' |
| 86 | ) |
| 87 | }) |
| 88 | |
| 89 | it('applies SOCKS proxy via Socks5ProxyAgent', async () => { |
| 90 | const { applyProxy } = await loadProxyModule() |
| 91 | |
| 92 | applyProxy('socks5://127.0.0.1:1080') |
| 93 | |
| 94 | expect(proxyTestState.Socks5ProxyAgentMock).toHaveBeenCalledWith('socks5://127.0.0.1:1080') |
| 95 | expect(proxyTestState.ProxyAgentMock).not.toHaveBeenCalled() |
| 96 | expect(proxyTestState.createdDispatchers[0]?.kind).toBe('socks') |
| 97 | }) |
| 98 | |
| 99 | it('closes the previous proxy dispatcher before switching to a new one', async () => { |
| 100 | const { applyProxy } = await loadProxyModule() |
| 101 | |
| 102 | applyProxy('http://127.0.0.1:7890') |
| 103 | const firstDispatcher = proxyTestState.createdDispatchers[0] |
| 104 | |
| 105 | applyProxy('http://127.0.0.1:7891') |
| 106 | |
| 107 | expect(firstDispatcher.close).toHaveBeenCalledTimes(1) |
| 108 | expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenNthCalledWith( |
| 109 | 2, |
| 110 | proxyTestState.createdDispatchers[1] |
| 111 | ) |
| 112 | }) |
| 113 | |
| 114 | it('restores the original dispatcher when the proxy URL is empty', async () => { |
| 115 | const { applyProxy } = await loadProxyModule() |
| 116 | |
| 117 | applyProxy('http://127.0.0.1:7890') |
| 118 | const activeProxyDispatcher = proxyTestState.createdDispatchers[0] |
| 119 | |
| 120 | applyProxy(' ') |
| 121 | |
| 122 | expect(activeProxyDispatcher.close).toHaveBeenCalledTimes(1) |
| 123 | expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenLastCalledWith( |
| 124 | proxyTestState.originalDispatcher |
| 125 | ) |
| 126 | expect(proxyTestState.logMock.info).toHaveBeenCalledWith( |
| 127 | '[proxy] cleared, restored default dispatcher' |
| 128 | ) |
| 129 | }) |
| 130 | |
| 131 | it('ignores async close rejections from the previous proxy dispatcher', async () => { |
| 132 | const { applyProxy, clearProxy } = await loadProxyModule() |
| 133 | |
| 134 | applyProxy('http://127.0.0.1:7890') |
| 135 | const activeProxyDispatcher = proxyTestState.createdDispatchers[0] |
| 136 | activeProxyDispatcher.close.mockRejectedValueOnce(new Error('close failed')) |
| 137 | |
| 138 | expect(() => clearProxy()).not.toThrow() |
| 139 | await Promise.resolve() |
| 140 | |
| 141 | expect(activeProxyDispatcher.close).toHaveBeenCalledTimes(1) |
| 142 | expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenLastCalledWith( |
| 143 | proxyTestState.originalDispatcher |
| 144 | ) |
| 145 | }) |
| 146 | }) |
| 147 |