Repository navigation
Expand file tree
/
Copy pathsdk.test.ts
More file actions
138 lines (112 loc) · 5.29 KB
/
Copy pathsdk.test.ts
File metadata and controls
138 lines (112 loc) · 5.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================
// SDK 客户端请求生命周期测试(OpenCode v2)
//
// v2 用 `OpenCode.make({ baseUrl, headers, fetch })` 建客户端;
// 我们注入的 fetch 承担两件事:
// 1. 在 Tauri 下换成 plugin-http 的 fetch
// 2. 记录在途请求,服务器端点变化时统一中断
//
// 这里直接测注入进去的那个 fetch,从而验证上述行为。
// ============================================
const { makeMock, getActiveBaseUrlMock, getActiveAuthMock, isTauriMock } = vi.hoisted(() => ({
makeMock: vi.fn((options: { fetch?: typeof globalThis.fetch }) => ({
__fetch: options.fetch,
__options: options,
})),
getActiveBaseUrlMock: vi.fn(() => 'http://127.0.0.1:4096'),
getActiveAuthMock: vi.fn(() => null),
isTauriMock: vi.fn(() => false),
}))
vi.mock('@opencode/client/promise', () => ({
OpenCode: { make: makeMock },
}))
vi.mock('../store/serverStore', () => ({
makeBasicAuthHeader: vi.fn(() => 'Basic token'),
serverStore: {
getActiveBaseUrl: getActiveBaseUrlMock,
getActiveAuth: getActiveAuthMock,
},
}))
vi.mock('../utils/tauri', () => ({
isTauri: isTauriMock,
}))
type MockClient = {
__fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
__options: { baseUrl: string; headers?: Record<string, string> }
}
describe('v2 client request lifecycle', () => {
beforeEach(async () => {
vi.restoreAllMocks()
makeMock.mockClear()
getActiveBaseUrlMock.mockReturnValue('http://127.0.0.1:4096')
getActiveAuthMock.mockReturnValue(null)
isTauriMock.mockReturnValue(false)
const { abortInFlightApiRequests, invalidateSDKClient } = await import('./sdk')
abortInFlightApiRequests('reset test state')
invalidateSDKClient()
})
it('builds the client from the active server base URL', async () => {
const { getSDKClient } = await import('./sdk')
const client = getSDKClient() as unknown as MockClient
expect(makeMock).toHaveBeenCalledTimes(1)
expect(client.__options.baseUrl).toBe('http://127.0.0.1:4096')
})
it('aborts in-flight requests when the server endpoint changes', async () => {
const { abortInFlightApiRequests, getSDKClient } = await import('./sdk')
const client = getSDKClient() as unknown as MockClient
let signal: AbortSignal | undefined
// 用注入的 fetch(不是全局 fetch)发起一个永不 resolve 的请求
vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => {
signal = init?.signal ?? undefined
return new Promise<Response>((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal?.reason), { once: true })
})
})
const request = client.__fetch('http://127.0.0.1:4096/api/session')
abortInFlightApiRequests('Server endpoint changed')
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
expect(signal?.aborted).toBe(true)
})
it('prevents stale clients from starting new requests after endpoint changes', async () => {
const { abortInFlightApiRequests, getSDKClient } = await import('./sdk')
const client = getSDKClient() as unknown as MockClient
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
abortInFlightApiRequests('Server endpoint changed')
// 旧客户端(代次落后)必须在起飞前就失败,不能真的发出请求
await expect(client.__fetch('http://127.0.0.1:4096/api/session')).rejects.toMatchObject({
name: 'AbortError',
})
expect(fetchSpy).not.toHaveBeenCalled()
})
it('rebuilds the client when the server changes', async () => {
const { getSDKClient, invalidateSDKClient } = await import('./sdk')
const first = getSDKClient() as unknown as MockClient
expect(first.__options.baseUrl).toBe('http://127.0.0.1:4096')
// 同 key 命中缓存
expect(getSDKClient()).toBe(first)
getActiveBaseUrlMock.mockReturnValue('http://127.0.0.1:5000')
invalidateSDKClient()
const second = getSDKClient() as unknown as MockClient
expect(second.__options.baseUrl).toBe('http://127.0.0.1:5000')
expect(second).not.toBe(first)
})
it('does not poison the client cache: getSDKClient after abort works again', async () => {
// 回归:abortInFlightApiRequests 递增代次后,缓存 client 的 generation
// 快照永久落后,之后每个请求都抛 'Stale API request'(永久毒化)。
// 修复后 abort 会清空缓存,新 getSDKClient() 拿到新代次实例。
const { abortInFlightApiRequests, getSDKClient } = await import('./sdk')
const stale = getSDKClient() as unknown as MockClient
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'))
abortInFlightApiRequests('Server endpoint changed')
const fresh = getSDKClient() as unknown as MockClient
expect(fresh).not.toBe(stale)
// 新实例正常发请求;旧引用仍然在起飞前失败(防乱序语义保留)
await expect(fresh.__fetch('http://127.0.0.1:4096/api/session')).resolves.toBeInstanceOf(Response)
expect(fetchSpy).toHaveBeenCalledTimes(1)
await expect(stale.__fetch('http://127.0.0.1:4096/api/session')).rejects.toMatchObject({
name: 'AbortError',
})
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
})