返回 oh-my-ppt
proxy.test.ts
根目录 / tests / unit / utils / proxy.test.ts
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 destroy: ReturnType<typeof vi.fn>
8 dispatch: ReturnType<typeof vi.fn>
9 }
10
11 const proxyTestState = vi.hoisted(() => {
12 const createDispatcher = (kind: string, url = ''): MockDispatcher => ({
13 kind,
14 url,
15 close: vi.fn().mockResolvedValue(undefined),
16 destroy: vi.fn().mockResolvedValue(undefined),
17 dispatch: vi.fn(() => true)
18 })
19
20 const originalDispatcher = createDispatcher('original')
21 const createdDispatchers: MockDispatcher[] = []
22 const logMock = {
23 info: vi.fn()
24 }
25 const getGlobalDispatcherMock = vi.fn(() => originalDispatcher)
26 const setGlobalDispatcherMock = vi.fn()
27 const ProxyAgentMock = vi.fn(function (options: unknown) {
28 const url = typeof options === 'string' ? options : String((options as { uri: string }).uri)
29 const dispatcher = createDispatcher('proxy', url)
30 createdDispatchers.push(dispatcher)
31 return dispatcher
32 })
33 const Socks5ProxyAgentMock = vi.fn(function (url: string) {
34 const dispatcher = createDispatcher('socks', url)
35 createdDispatchers.push(dispatcher)
36 return dispatcher
37 })
38 const AgentMock = vi.fn(function () {
39 const dispatcher = createDispatcher('direct')
40 createdDispatchers.push(dispatcher)
41 return dispatcher
42 })
43
44 return {
45 AgentMock,
46 createdDispatchers,
47 getGlobalDispatcherMock,
48 logMock,
49 originalDispatcher,
50 ProxyAgentMock,
51 setGlobalDispatcherMock,
52 Socks5ProxyAgentMock
53 }
54 })
55
56 vi.mock('electron-log/main.js', () => ({
57 default: proxyTestState.logMock
58 }))
59
60 vi.mock('undici', () => {
61 class DispatcherMock {
62 dispatch(): boolean {
63 return false
64 }
65
66 async close(): Promise<void> {}
67
68 async destroy(): Promise<void> {}
69 }
70
71 return {
72 Agent: proxyTestState.AgentMock,
73 Dispatcher: DispatcherMock,
74 ProxyAgent: proxyTestState.ProxyAgentMock,
75 Socks5ProxyAgent: proxyTestState.Socks5ProxyAgentMock,
76 getGlobalDispatcher: proxyTestState.getGlobalDispatcherMock,
77 setGlobalDispatcher: proxyTestState.setGlobalDispatcherMock
78 }
79 })
80
81 async function loadProxyModule() {
82 vi.resetModules()
83 return import('../../../src/main/utils/proxy')
84 }
85
86 describe('proxy dispatcher management', () => {
87 beforeEach(() => {
88 proxyTestState.createdDispatchers.length = 0
89 proxyTestState.getGlobalDispatcherMock.mockClear()
90 proxyTestState.getGlobalDispatcherMock.mockReturnValue(proxyTestState.originalDispatcher)
91 proxyTestState.logMock.info.mockClear()
92 proxyTestState.originalDispatcher.close.mockClear()
93 proxyTestState.AgentMock.mockClear()
94 proxyTestState.ProxyAgentMock.mockClear()
95 proxyTestState.setGlobalDispatcherMock.mockClear()
96 proxyTestState.Socks5ProxyAgentMock.mockClear()
97 })
98
99 it('applies HTTP proxy via ProxyAgent and trims the URL', async () => {
100 const { applyProxy } = await loadProxyModule()
101
102 applyProxy({ url: ' http://127.0.0.1:7890 ' })
103
104 expect(proxyTestState.ProxyAgentMock).toHaveBeenCalledWith('http://127.0.0.1:7890')
105 expect(proxyTestState.Socks5ProxyAgentMock).not.toHaveBeenCalled()
106 expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenCalledWith(
107 proxyTestState.createdDispatchers[0]
108 )
109 expect(proxyTestState.logMock.info).toHaveBeenCalledWith(
110 '[proxy] applied',
111 expect.objectContaining({ url: 'http://127.0.0.1:7890', hasAuth: false })
112 )
113 })
114
115 it('sends proxy auth as a Basic token for HTTP proxies', async () => {
116 const { applyProxy } = await loadProxyModule()
117
118 applyProxy({
119 url: 'http://127.0.0.1:7890',
120 username: 'corp\\user',
121 password: 'secret'
122 })
123
124 expect(proxyTestState.ProxyAgentMock).toHaveBeenCalledWith({
125 uri: 'http://127.0.0.1:7890',
126 token: `Basic ${Buffer.from('corp\\user:secret').toString('base64')}`
127 })
128 })
129
130 it('applies SOCKS proxy with username and password options', async () => {
131 const { applyProxy } = await loadProxyModule()
132
133 applyProxy({
134 url: 'socks5://127.0.0.1:1080',
135 username: 'user',
136 password: 'pass'
137 })
138
139 expect(proxyTestState.Socks5ProxyAgentMock).toHaveBeenCalledWith('socks5://127.0.0.1:1080', {
140 username: 'user',
141 password: 'pass'
142 })
143 expect(proxyTestState.ProxyAgentMock).not.toHaveBeenCalled()
144 })
145
146 it('routes no_proxy hosts direct and everything else through the proxy', async () => {
147 const { applyProxy } = await loadProxyModule()
148
149 applyProxy({
150 url: 'http://127.0.0.1:7890',
151 noProxy: 'localhost,10.0.0.0/8,*.corp.internal'
152 })
153
154 // 直连 dispatcher(Agent)和代理 dispatcher 都被创建,全局 dispatcher 是分流包装器
155 const directDispatcher = proxyTestState.createdDispatchers.find((d) => d.kind === 'direct')
156 const proxyDispatcher = proxyTestState.createdDispatchers.find((d) => d.kind === 'proxy')
157 expect(directDispatcher).toBeDefined()
158 expect(proxyDispatcher).toBeDefined()
159 expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenCalledWith(
160 expect.objectContaining({ constructor: expect.any(Function) })
161 )
162 const activeDispatcher = proxyTestState.setGlobalDispatcherMock.mock.calls[0][0] as {
163 dispatch: (options: { origin: string }, handler: unknown) => boolean
164 }
165
166 const handler = {}
167 activeDispatcher.dispatch({ origin: 'http://10.1.2.3:8000/v1/models' }, handler)
168 expect(directDispatcher?.dispatch).toHaveBeenCalled()
169 expect(proxyDispatcher?.dispatch).not.toHaveBeenCalled()
170
171 directDispatcher?.dispatch.mockClear()
172 activeDispatcher.dispatch({ origin: 'https://api.openai.com/v1/models' }, handler)
173 expect(proxyDispatcher?.dispatch).toHaveBeenCalled()
174 expect(directDispatcher?.dispatch).not.toHaveBeenCalled()
175 })
176
177 it('bypasses the proxy entirely for the * wildcard', async () => {
178 const { applyProxy } = await loadProxyModule()
179
180 applyProxy({ url: 'http://127.0.0.1:7890', noProxy: '*' })
181
182 expect(proxyTestState.ProxyAgentMock).not.toHaveBeenCalled()
183 expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenLastCalledWith(
184 proxyTestState.originalDispatcher
185 )
186 })
187
188 it('closes the previous proxy dispatcher before switching to a new one', async () => {
189 const { applyProxy } = await loadProxyModule()
190
191 applyProxy({ url: 'http://127.0.0.1:7890' })
192 const firstDispatcher = proxyTestState.createdDispatchers[0]
193
194 applyProxy({ url: 'http://127.0.0.1:7891' })
195
196 expect(firstDispatcher.close).toHaveBeenCalledTimes(1)
197 expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenNthCalledWith(
198 2,
199 proxyTestState.createdDispatchers[1]
200 )
201 })
202
203 it('restores the original dispatcher when the proxy URL is empty', async () => {
204 const { applyProxy } = await loadProxyModule()
205
206 applyProxy({ url: 'http://127.0.0.1:7890' })
207 const activeProxyDispatcher = proxyTestState.createdDispatchers[0]
208
209 applyProxy({ url: ' ' })
210
211 expect(activeProxyDispatcher.close).toHaveBeenCalledTimes(1)
212 expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenLastCalledWith(
213 proxyTestState.originalDispatcher
214 )
215 expect(proxyTestState.logMock.info).toHaveBeenCalledWith(
216 '[proxy] cleared, restored default dispatcher'
217 )
218 })
219
220 it('ignores async close rejections from the previous proxy dispatcher', async () => {
221 const { applyProxy, clearProxy } = await loadProxyModule()
222
223 applyProxy({ url: 'http://127.0.0.1:7890' })
224 const activeProxyDispatcher = proxyTestState.createdDispatchers[0]
225 activeProxyDispatcher.close.mockRejectedValueOnce(new Error('close failed'))
226
227 expect(() => clearProxy()).not.toThrow()
228 await Promise.resolve()
229
230 expect(activeProxyDispatcher.close).toHaveBeenCalledTimes(1)
231 expect(proxyTestState.setGlobalDispatcherMock).toHaveBeenLastCalledWith(
232 proxyTestState.originalDispatcher
233 )
234 })
235 })
236
236 lines TYPESCRIPT