Skip to content

Commit 381b078

Browse files
committed
Feat ContextProxy to improve state management
- Add ContextProxy class as a wrapper around VSCode's ExtensionContext - Implement batched state updates for performance optimization - Update ClineProvider to use ContextProxy instead of direct context access - Add comprehensive test coverage for ContextProxy - Extract SECRET_KEYS and GLOBAL_STATE_KEYS constants for better maintainability
1 parent 541b54e commit 381b078

File tree

5 files changed

+797
-406
lines changed

5 files changed

+797
-406
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import * as vscode from "vscode"
2+
import { ContextProxy } from "../contextProxy"
3+
import { logger } from "../../utils/logging"
4+
5+
// Mock the logger
6+
jest.mock("../../utils/logging", () => ({
7+
logger: {
8+
debug: jest.fn(),
9+
info: jest.fn(),
10+
warn: jest.fn(),
11+
error: jest.fn(),
12+
},
13+
}))
14+
15+
// Mock VSCode API
16+
jest.mock("vscode", () => ({
17+
Uri: {
18+
file: jest.fn((path) => ({ path })),
19+
},
20+
ExtensionMode: {
21+
Development: 1,
22+
Production: 2,
23+
Test: 3,
24+
},
25+
}))
26+
27+
describe("ContextProxy", () => {
28+
let proxy: ContextProxy
29+
let mockContext: any
30+
let mockGlobalState: any
31+
let mockSecrets: any
32+
33+
beforeEach(() => {
34+
// Reset mocks
35+
jest.clearAllMocks()
36+
37+
// Mock globalState
38+
mockGlobalState = {
39+
get: jest.fn(),
40+
update: jest.fn().mockResolvedValue(undefined),
41+
}
42+
43+
// Mock secrets
44+
mockSecrets = {
45+
get: jest.fn(),
46+
store: jest.fn().mockResolvedValue(undefined),
47+
delete: jest.fn().mockResolvedValue(undefined),
48+
}
49+
50+
// Mock the extension context
51+
mockContext = {
52+
globalState: mockGlobalState,
53+
secrets: mockSecrets,
54+
extensionUri: { path: "/test/extension" },
55+
extensionPath: "/test/extension",
56+
globalStorageUri: { path: "/test/storage" },
57+
logUri: { path: "/test/logs" },
58+
extension: { packageJSON: { version: "1.0.0" } },
59+
extensionMode: vscode.ExtensionMode.Development,
60+
}
61+
62+
// Create proxy instance
63+
proxy = new ContextProxy(mockContext)
64+
})
65+
66+
describe("read-only pass-through properties", () => {
67+
it("should return extension properties from the original context", () => {
68+
expect(proxy.extensionUri).toBe(mockContext.extensionUri)
69+
expect(proxy.extensionPath).toBe(mockContext.extensionPath)
70+
expect(proxy.globalStorageUri).toBe(mockContext.globalStorageUri)
71+
expect(proxy.logUri).toBe(mockContext.logUri)
72+
expect(proxy.extension).toBe(mockContext.extension)
73+
expect(proxy.extensionMode).toBe(mockContext.extensionMode)
74+
})
75+
})
76+
77+
describe("getGlobalState", () => {
78+
it("should return pending change when it exists", async () => {
79+
// Set up a pending change
80+
await proxy.updateGlobalState("test-key", "new-value")
81+
82+
// Should return the pending value
83+
const result = await proxy.getGlobalState("test-key")
84+
expect(result).toBe("new-value")
85+
86+
// Original context should not be called
87+
expect(mockGlobalState.get).not.toHaveBeenCalled()
88+
})
89+
90+
it("should fall back to original context when no pending change exists", async () => {
91+
// Set up original context value
92+
mockGlobalState.get.mockReturnValue("original-value")
93+
94+
// Should get from original context
95+
const result = await proxy.getGlobalState("test-key")
96+
expect(result).toBe("original-value")
97+
expect(mockGlobalState.get).toHaveBeenCalledWith("test-key", undefined)
98+
})
99+
100+
it("should handle default values correctly", async () => {
101+
// No value in either pending or original
102+
mockGlobalState.get.mockImplementation((key: string, defaultValue: any) => defaultValue)
103+
104+
// Should return the default value
105+
const result = await proxy.getGlobalState("test-key", "default-value")
106+
expect(result).toBe("default-value")
107+
})
108+
})
109+
110+
describe("updateGlobalState", () => {
111+
it("should buffer changes without calling original context", async () => {
112+
await proxy.updateGlobalState("test-key", "new-value")
113+
114+
// Should have called logger.debug
115+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering state update"))
116+
117+
// Should not have called original context
118+
expect(mockGlobalState.update).not.toHaveBeenCalled()
119+
120+
// Should have stored the value in pendingStateChanges
121+
const storedValue = await proxy.getGlobalState("test-key")
122+
expect(storedValue).toBe("new-value")
123+
})
124+
125+
it("should throw an error when context is disposed", async () => {
126+
await proxy.dispose()
127+
128+
await expect(proxy.updateGlobalState("test-key", "new-value")).rejects.toThrow(
129+
"Cannot update state on disposed context",
130+
)
131+
})
132+
})
133+
134+
describe("getSecret", () => {
135+
it("should return pending secret when it exists", async () => {
136+
// Set up a pending secret
137+
await proxy.storeSecret("api-key", "secret123")
138+
139+
// Should return the pending value
140+
const result = await proxy.getSecret("api-key")
141+
expect(result).toBe("secret123")
142+
143+
// Original context should not be called
144+
expect(mockSecrets.get).not.toHaveBeenCalled()
145+
})
146+
147+
it("should fall back to original context when no pending secret exists", async () => {
148+
// Set up original context value
149+
mockSecrets.get.mockResolvedValue("original-secret")
150+
151+
// Should get from original context
152+
const result = await proxy.getSecret("api-key")
153+
expect(result).toBe("original-secret")
154+
expect(mockSecrets.get).toHaveBeenCalledWith("api-key")
155+
})
156+
})
157+
158+
describe("storeSecret", () => {
159+
it("should buffer secret changes without calling original context", async () => {
160+
await proxy.storeSecret("api-key", "new-secret")
161+
162+
// Should have called logger.debug
163+
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("buffering secret update"))
164+
165+
// Should not have called original context
166+
expect(mockSecrets.store).not.toHaveBeenCalled()
167+
168+
// Should have stored the value in pendingSecretChanges
169+
const storedValue = await proxy.getSecret("api-key")
170+
expect(storedValue).toBe("new-secret")
171+
})
172+
173+
it("should handle undefined value for secret deletion", async () => {
174+
await proxy.storeSecret("api-key", undefined)
175+
176+
// Should have stored undefined in pendingSecretChanges
177+
const storedValue = await proxy.getSecret("api-key")
178+
expect(storedValue).toBeUndefined()
179+
})
180+
181+
it("should throw an error when context is disposed", async () => {
182+
await proxy.dispose()
183+
184+
await expect(proxy.storeSecret("api-key", "new-secret")).rejects.toThrow(
185+
"Cannot store secret on disposed context",
186+
)
187+
})
188+
})
189+
190+
describe("saveChanges", () => {
191+
it("should apply state changes to original context", async () => {
192+
// Set up pending changes
193+
await proxy.updateGlobalState("key1", "value1")
194+
await proxy.updateGlobalState("key2", "value2")
195+
196+
// Save changes
197+
await proxy.saveChanges()
198+
199+
// Should have called update on original context
200+
expect(mockGlobalState.update).toHaveBeenCalledTimes(2)
201+
expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1")
202+
expect(mockGlobalState.update).toHaveBeenCalledWith("key2", "value2")
203+
204+
// Should have cleared pending changes
205+
expect(proxy.hasPendingChanges()).toBe(false)
206+
})
207+
208+
it("should apply secret changes to original context", async () => {
209+
// Set up pending changes
210+
await proxy.storeSecret("secret1", "value1")
211+
await proxy.storeSecret("secret2", undefined)
212+
213+
// Save changes
214+
await proxy.saveChanges()
215+
216+
// Should have called store and delete on original context
217+
expect(mockSecrets.store).toHaveBeenCalledTimes(1)
218+
expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1")
219+
expect(mockSecrets.delete).toHaveBeenCalledTimes(1)
220+
expect(mockSecrets.delete).toHaveBeenCalledWith("secret2")
221+
222+
// Should have cleared pending changes
223+
expect(proxy.hasPendingChanges()).toBe(false)
224+
})
225+
226+
it("should do nothing when there are no pending changes", async () => {
227+
await proxy.saveChanges()
228+
229+
expect(mockGlobalState.update).not.toHaveBeenCalled()
230+
expect(mockSecrets.store).not.toHaveBeenCalled()
231+
expect(mockSecrets.delete).not.toHaveBeenCalled()
232+
})
233+
234+
it("should throw an error when context is disposed", async () => {
235+
await proxy.dispose()
236+
237+
await expect(proxy.saveChanges()).rejects.toThrow("Cannot save changes on disposed context")
238+
})
239+
})
240+
241+
describe("dispose", () => {
242+
it("should save pending changes to original context", async () => {
243+
// Set up pending changes
244+
await proxy.updateGlobalState("key1", "value1")
245+
await proxy.storeSecret("secret1", "value1")
246+
247+
// Dispose
248+
await proxy.dispose()
249+
250+
// Should have saved changes
251+
expect(mockGlobalState.update).toHaveBeenCalledWith("key1", "value1")
252+
expect(mockSecrets.store).toHaveBeenCalledWith("secret1", "value1")
253+
254+
// Should be marked as disposed
255+
expect(proxy.hasPendingChanges()).toBe(false)
256+
})
257+
})
258+
259+
describe("hasPendingChanges", () => {
260+
it("should return false when no changes are pending", () => {
261+
expect(proxy.hasPendingChanges()).toBe(false)
262+
})
263+
264+
it("should return true when state changes are pending", async () => {
265+
await proxy.updateGlobalState("key", "value")
266+
expect(proxy.hasPendingChanges()).toBe(true)
267+
})
268+
269+
it("should return true when secret changes are pending", async () => {
270+
await proxy.storeSecret("key", "value")
271+
expect(proxy.hasPendingChanges()).toBe(true)
272+
})
273+
274+
it("should return false after changes are saved", async () => {
275+
await proxy.updateGlobalState("key", "value")
276+
expect(proxy.hasPendingChanges()).toBe(true)
277+
278+
await proxy.saveChanges()
279+
expect(proxy.hasPendingChanges()).toBe(false)
280+
})
281+
})
282+
})

0 commit comments

Comments
 (0)