Skip to content

Commit 52df12c

Browse files
authored
refactor(storage): split storage.ts into isolated modules; add unit tests; upgrade vitest (coder#589)
Closes coder#588 This PR refactors `storage.ts` into small, focused modules that are straightforward to unit test (with mocks). It also upgrades `vitest` to a version that plays nicely with VS Code extensions so we can view coverage and run/debug tests directly in VS Code. Key changes - Extract path resolution from `storage.ts` → dedicated module - Extract memento & secrets management from `storage.ts` → dedicated module - Extract and fully separate CLI management logic → dedicated module - Remove `storage.ts` entirely in favor of the new modules - Add unit tests for the split modules - Upgrade `vitest` and related tooling for VS Code extension testing Why mock `vscode`? - Unit tests (mocked `vscode`): fast, reliable, deterministic validation of module behavior without depending on VS Code APIs or external calls (e.g., Axios). - Integration/E2E tests (real VS Code): cover end-to-end flows by launching VS Code (and eventually a server). Valuable but slower and harder to automate; we reserve these for scenarios that require the actual runtime.
1 parent dc4d6d4 commit 52df12c

24 files changed

+2760
-1224
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,6 @@
316316
"eventsource": "^3.0.6",
317317
"find-process": "https://github.com/coder/find-process#fix/sequoia-compat",
318318
"jsonc-parser": "^3.3.1",
319-
"memfs": "^4.17.1",
320319
"node-forge": "^1.3.1",
321320
"openpgp": "^6.2.0",
322321
"pretty-bytes": "^7.0.0",
@@ -336,6 +335,7 @@
336335
"@types/ws": "^8.18.1",
337336
"@typescript-eslint/eslint-plugin": "^7.0.0",
338337
"@typescript-eslint/parser": "^6.21.0",
338+
"@vitest/coverage-v8": "^3.2.4",
339339
"@vscode/test-cli": "^0.0.11",
340340
"@vscode/test-electron": "^2.5.2",
341341
"@vscode/vsce": "^3.6.0",
@@ -350,12 +350,13 @@
350350
"eslint-plugin-prettier": "^5.4.1",
351351
"glob": "^10.4.2",
352352
"jsonc-eslint-parser": "^2.4.0",
353+
"memfs": "^4.46.0",
353354
"nyc": "^17.1.0",
354355
"prettier": "^3.5.3",
355356
"ts-loader": "^9.5.1",
356357
"typescript": "^5.8.3",
357358
"utf-8-validate": "^6.0.5",
358-
"vitest": "^0.34.6",
359+
"vitest": "^3.2.4",
359360
"vscode-test": "^1.5.0",
360361
"webpack": "^5.99.6",
361362
"webpack-cli": "^5.1.4"

src/__mocks__/testHelpers.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { vi } from "vitest";
2+
import * as vscode from "vscode";
3+
4+
/**
5+
* Mock configuration provider that integrates with the vscode workspace configuration mock.
6+
* Use this to set configuration values that will be returned by vscode.workspace.getConfiguration().
7+
*/
8+
export class MockConfigurationProvider {
9+
private config = new Map<string, unknown>();
10+
11+
constructor() {
12+
this.setupVSCodeMock();
13+
}
14+
15+
/**
16+
* Set a configuration value that will be returned by vscode.workspace.getConfiguration().get()
17+
*/
18+
set(key: string, value: unknown): void {
19+
this.config.set(key, value);
20+
}
21+
22+
/**
23+
* Get a configuration value (for testing purposes)
24+
*/
25+
get<T>(key: string): T | undefined;
26+
get<T>(key: string, defaultValue: T): T;
27+
get<T>(key: string, defaultValue?: T): T | undefined {
28+
const value = this.config.get(key);
29+
return value !== undefined ? (value as T) : defaultValue;
30+
}
31+
32+
/**
33+
* Clear all configuration values
34+
*/
35+
clear(): void {
36+
this.config.clear();
37+
}
38+
39+
/**
40+
* Setup the vscode.workspace.getConfiguration mock to return our values
41+
*/
42+
private setupVSCodeMock(): void {
43+
vi.mocked(vscode.workspace.getConfiguration).mockImplementation(
44+
(section?: string) => {
45+
// Create a snapshot of the current config when getConfiguration is called
46+
const snapshot = new Map(this.config);
47+
const getFullKey = (part: string) =>
48+
section ? `${section}.${part}` : part;
49+
50+
return {
51+
get: vi.fn((key: string, defaultValue?: unknown) => {
52+
const value = snapshot.get(getFullKey(key));
53+
return value !== undefined ? value : defaultValue;
54+
}),
55+
has: vi.fn((key: string) => {
56+
return snapshot.has(getFullKey(key));
57+
}),
58+
inspect: vi.fn(),
59+
update: vi.fn((key: string, value: unknown) => {
60+
this.config.set(getFullKey(key), value);
61+
return Promise.resolve();
62+
}),
63+
};
64+
},
65+
);
66+
}
67+
}
68+
69+
/**
70+
* Mock progress reporter that integrates with vscode.window.withProgress.
71+
* Use this to control progress reporting behavior and cancellation in tests.
72+
*/
73+
export class MockProgressReporter {
74+
private shouldCancel = false;
75+
private progressReports: Array<{ message?: string; increment?: number }> = [];
76+
77+
constructor() {
78+
this.setupVSCodeMock();
79+
}
80+
81+
/**
82+
* Set whether the progress should be cancelled
83+
*/
84+
setCancellation(cancel: boolean): void {
85+
this.shouldCancel = cancel;
86+
}
87+
88+
/**
89+
* Get all progress reports that were made
90+
*/
91+
getProgressReports(): Array<{ message?: string; increment?: number }> {
92+
return [...this.progressReports];
93+
}
94+
95+
/**
96+
* Clear all progress reports
97+
*/
98+
clearProgressReports(): void {
99+
this.progressReports = [];
100+
}
101+
102+
/**
103+
* Setup the vscode.window.withProgress mock
104+
*/
105+
private setupVSCodeMock(): void {
106+
vi.mocked(vscode.window.withProgress).mockImplementation(
107+
async <T>(
108+
_options: vscode.ProgressOptions,
109+
task: (
110+
progress: vscode.Progress<{ message?: string; increment?: number }>,
111+
token: vscode.CancellationToken,
112+
) => Thenable<T>,
113+
): Promise<T> => {
114+
const progress = {
115+
report: vi.fn((value: { message?: string; increment?: number }) => {
116+
this.progressReports.push(value);
117+
}),
118+
};
119+
120+
const cancellationToken: vscode.CancellationToken = {
121+
isCancellationRequested: this.shouldCancel,
122+
onCancellationRequested: vi.fn((listener: (x: unknown) => void) => {
123+
if (this.shouldCancel) {
124+
setTimeout(listener, 0);
125+
}
126+
return { dispose: vi.fn() };
127+
}),
128+
};
129+
130+
return task(progress, cancellationToken);
131+
},
132+
);
133+
}
134+
}
135+
136+
/**
137+
* Mock user interaction that integrates with vscode.window message dialogs.
138+
* Use this to control user responses in tests.
139+
*/
140+
export class MockUserInteraction {
141+
private responses = new Map<string, string | undefined>();
142+
private externalUrls: string[] = [];
143+
144+
constructor() {
145+
this.setupVSCodeMock();
146+
}
147+
148+
/**
149+
* Set a response for a specific message
150+
*/
151+
setResponse(message: string, response: string | undefined): void {
152+
this.responses.set(message, response);
153+
}
154+
155+
/**
156+
* Get all URLs that were opened externally
157+
*/
158+
getExternalUrls(): string[] {
159+
return [...this.externalUrls];
160+
}
161+
162+
/**
163+
* Clear all external URLs
164+
*/
165+
clearExternalUrls(): void {
166+
this.externalUrls = [];
167+
}
168+
169+
/**
170+
* Clear all responses
171+
*/
172+
clearResponses(): void {
173+
this.responses.clear();
174+
}
175+
176+
/**
177+
* Setup the vscode.window message dialog mocks
178+
*/
179+
private setupVSCodeMock(): void {
180+
const getResponse = (message: string): string | undefined => {
181+
return this.responses.get(message);
182+
};
183+
184+
vi.mocked(vscode.window.showErrorMessage).mockImplementation(
185+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
186+
(message: string): Thenable<any> => {
187+
const response = getResponse(message);
188+
return Promise.resolve(response);
189+
},
190+
);
191+
192+
vi.mocked(vscode.window.showWarningMessage).mockImplementation(
193+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
194+
(message: string): Thenable<any> => {
195+
const response = getResponse(message);
196+
return Promise.resolve(response);
197+
},
198+
);
199+
200+
vi.mocked(vscode.window.showInformationMessage).mockImplementation(
201+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
202+
(message: string): Thenable<any> => {
203+
const response = getResponse(message);
204+
return Promise.resolve(response);
205+
},
206+
);
207+
208+
vi.mocked(vscode.env.openExternal).mockImplementation(
209+
(target: vscode.Uri): Promise<boolean> => {
210+
this.externalUrls.push(target.toString());
211+
return Promise.resolve(true);
212+
},
213+
);
214+
}
215+
}
216+
217+
// Simple in-memory implementation of Memento
218+
export class InMemoryMemento implements vscode.Memento {
219+
private storage = new Map<string, unknown>();
220+
221+
get<T>(key: string): T | undefined;
222+
get<T>(key: string, defaultValue: T): T;
223+
get<T>(key: string, defaultValue?: T): T | undefined {
224+
return this.storage.has(key) ? (this.storage.get(key) as T) : defaultValue;
225+
}
226+
227+
async update(key: string, value: unknown): Promise<void> {
228+
if (value === undefined) {
229+
this.storage.delete(key);
230+
} else {
231+
this.storage.set(key, value);
232+
}
233+
return Promise.resolve();
234+
}
235+
236+
keys(): readonly string[] {
237+
return Array.from(this.storage.keys());
238+
}
239+
}
240+
241+
// Simple in-memory implementation of SecretStorage
242+
export class InMemorySecretStorage implements vscode.SecretStorage {
243+
private secrets = new Map<string, string>();
244+
private isCorrupted = false;
245+
246+
onDidChange: vscode.Event<vscode.SecretStorageChangeEvent> = () => ({
247+
dispose: () => {},
248+
});
249+
250+
async get(key: string): Promise<string | undefined> {
251+
if (this.isCorrupted) {
252+
return Promise.reject(new Error("Storage corrupted"));
253+
}
254+
return this.secrets.get(key);
255+
}
256+
257+
async store(key: string, value: string): Promise<void> {
258+
if (this.isCorrupted) {
259+
return Promise.reject(new Error("Storage corrupted"));
260+
}
261+
this.secrets.set(key, value);
262+
}
263+
264+
async delete(key: string): Promise<void> {
265+
if (this.isCorrupted) {
266+
return Promise.reject(new Error("Storage corrupted"));
267+
}
268+
this.secrets.delete(key);
269+
}
270+
271+
corruptStorage(): void {
272+
this.isCorrupted = true;
273+
}
274+
}

0 commit comments

Comments
 (0)