|
11 | 11 | // a fresh object per call keeps tests isolated — none of the classes |
12 | 12 | // or stubs leak state between files. |
13 | 13 | // |
14 | | -// `getDiagnostics` reads from `globalThis.__stubDiagnostics`, which |
15 | | -// tests populate via the per-file `setStubDiagnostics` helper they |
16 | | -// keep close to their fixtures. |
| 14 | +// Tests drive behaviour through `globalThis.__stub*` slots and read |
| 15 | +// observations back through `globalThis.__stubCalls`. Per-file |
| 16 | +// `beforeEach` is expected to reset both. Each slot is documented |
| 17 | +// inline below so a new test can find its hook without re-reading the |
| 18 | +// whole stub. |
| 19 | + |
| 20 | +export interface StubFindFilesCall { |
| 21 | + readonly include: string; |
| 22 | + readonly exclude?: string; |
| 23 | + readonly maxResults?: number; |
| 24 | +} |
| 25 | + |
| 26 | +export interface StubTerminalCall { |
| 27 | + readonly name: string; |
| 28 | + readonly shown: boolean; |
| 29 | + readonly sent: ReadonlyArray<{ readonly text: string; readonly addNewLine: boolean }>; |
| 30 | +} |
| 31 | + |
| 32 | +export interface StubExecuteCommandCall { |
| 33 | + readonly command: string; |
| 34 | + readonly args: readonly unknown[]; |
| 35 | +} |
| 36 | + |
| 37 | +export interface StubClipboardWrite { |
| 38 | + readonly text: string; |
| 39 | +} |
| 40 | + |
| 41 | +export interface StubStatusBarMessage { |
| 42 | + readonly text: string; |
| 43 | + readonly hideAfterMs?: number; |
| 44 | +} |
| 45 | + |
| 46 | +export interface StubStatusBarItem { |
| 47 | + text: string; |
| 48 | + tooltip: unknown; |
| 49 | + command: unknown; |
| 50 | + name: string; |
| 51 | + backgroundColor: unknown; |
| 52 | + accessibilityInformation: { label: string } | undefined; |
| 53 | + shown: boolean; |
| 54 | + disposed: boolean; |
| 55 | + // Observable counters help tests pin "did update() fire?" without |
| 56 | + // racing against the implementation's debouncing. |
| 57 | + showCount: number; |
| 58 | + hideCount: number; |
| 59 | +} |
| 60 | + |
| 61 | +interface StubCalls { |
| 62 | + readonly findFiles: StubFindFilesCall[]; |
| 63 | + readonly terminals: StubTerminalCall[]; |
| 64 | + readonly executeCommand: StubExecuteCommandCall[]; |
| 65 | + readonly clipboardWrites: StubClipboardWrite[]; |
| 66 | + readonly statusBarMessages: StubStatusBarMessage[]; |
| 67 | + readonly statusBarItems: StubStatusBarItem[]; |
| 68 | + readonly infoMessages: string[]; |
| 69 | + readonly warningMessages: string[]; |
| 70 | + readonly errorMessages: string[]; |
| 71 | +} |
| 72 | + |
| 73 | +declare global { |
| 74 | + var __stubConfig: Record<string, unknown> | undefined; |
| 75 | + var __stubDiagnostics: |
| 76 | + | Array<[{ toString: () => string }, unknown[]]> |
| 77 | + | undefined; |
| 78 | + // Findings of `findFiles`. Tests may either set a single `__stubFindFiles` |
| 79 | + // (used for every include glob) or `__stubFindFilesByPattern` (a map |
| 80 | + // from include glob → URI list, queried per call). |
| 81 | + var __stubFindFiles: Array<{ toString: () => string; fsPath: string }> | undefined; |
| 82 | + var __stubFindFilesByPattern: |
| 83 | + | Record<string, Array<{ toString: () => string; fsPath: string }>> |
| 84 | + | undefined; |
| 85 | + // What `workspace.workspaceFolders` should return for the duration |
| 86 | + // of a test. `undefined` mimics "no workspace open". |
| 87 | + var __stubWorkspaceFolders: |
| 88 | + | Array<{ uri: { toString: () => string; fsPath: string } }> |
| 89 | + | undefined; |
| 90 | + // URI strings that `openTextDocument` should reject for (simulating |
| 91 | + // a read error / unsupported encoding). All other URIs resolve. |
| 92 | + var __stubOpenTextDocumentFailures: Set<string> | undefined; |
| 93 | + // When true, `withProgress` reports cancellation back to the task |
| 94 | + // immediately. Used by the scanWorkspace cancellation test. |
| 95 | + var __stubProgressCancelled: boolean | undefined; |
| 96 | + var __stubCalls: StubCalls | undefined; |
| 97 | +} |
| 98 | + |
| 99 | +function ensureCalls(): StubCalls { |
| 100 | + if (!globalThis.__stubCalls) { |
| 101 | + globalThis.__stubCalls = { |
| 102 | + findFiles: [], |
| 103 | + terminals: [], |
| 104 | + executeCommand: [], |
| 105 | + clipboardWrites: [], |
| 106 | + statusBarMessages: [], |
| 107 | + statusBarItems: [], |
| 108 | + infoMessages: [], |
| 109 | + warningMessages: [], |
| 110 | + errorMessages: [], |
| 111 | + }; |
| 112 | + } |
| 113 | + return globalThis.__stubCalls; |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * Reset every observable slot in one place. Tests call this in |
| 118 | + * `beforeEach` to keep their assertions isolated. The factory itself |
| 119 | + * does not reset; calling `vscodeStub()` returns a fresh module shape |
| 120 | + * but reuses the global slots so tests across files don't fight. |
| 121 | + */ |
| 122 | +export function resetStubState(): void { |
| 123 | + globalThis.__stubConfig = {}; |
| 124 | + globalThis.__stubDiagnostics = []; |
| 125 | + globalThis.__stubFindFiles = undefined; |
| 126 | + globalThis.__stubFindFilesByPattern = undefined; |
| 127 | + globalThis.__stubWorkspaceFolders = undefined; |
| 128 | + globalThis.__stubOpenTextDocumentFailures = undefined; |
| 129 | + globalThis.__stubProgressCancelled = undefined; |
| 130 | + globalThis.__stubCalls = { |
| 131 | + findFiles: [], |
| 132 | + terminals: [], |
| 133 | + executeCommand: [], |
| 134 | + clipboardWrites: [], |
| 135 | + statusBarMessages: [], |
| 136 | + statusBarItems: [], |
| 137 | + infoMessages: [], |
| 138 | + warningMessages: [], |
| 139 | + errorMessages: [], |
| 140 | + }; |
| 141 | +} |
17 | 142 |
|
18 | 143 | export function vscodeStub(): Record<string, unknown> { |
19 | 144 | class ThemeIcon { |
@@ -72,6 +197,7 @@ export function vscodeStub(): Record<string, unknown> { |
72 | 197 | }; |
73 | 198 | const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 }; |
74 | 199 | const StatusBarAlignment = { Left: 1, Right: 2 }; |
| 200 | + const ProgressLocation = { SourceControl: 1, Window: 10, Notification: 15 }; |
75 | 201 |
|
76 | 202 | class Range { |
77 | 203 | constructor( |
@@ -102,51 +228,194 @@ export function vscodeStub(): Record<string, unknown> { |
102 | 228 | MarkdownString, |
103 | 229 | TreeItemCollapsibleState, |
104 | 230 | StatusBarAlignment, |
| 231 | + ProgressLocation, |
105 | 232 | Range, |
106 | 233 | CodeLens, |
107 | 234 | Uri, |
108 | 235 | workspace: { |
109 | 236 | asRelativePath: (uri: { fsPath?: string; path?: string }) => |
110 | 237 | uri.fsPath ?? uri.path ?? "", |
| 238 | + // Getter so a single per-test override (writing to |
| 239 | + // `globalThis.__stubWorkspaceFolders`) reaches the consumer |
| 240 | + // without each test having to mutate the `vscode.workspace` |
| 241 | + // module reference. |
| 242 | + get workspaceFolders() { |
| 243 | + return globalThis.__stubWorkspaceFolders; |
| 244 | + }, |
111 | 245 | // `getConfiguration(section).get(key, fallback)` reads from |
112 | 246 | // `globalThis.__stubConfig`, a `Record<string, unknown>` keyed |
113 | 247 | // by `<section>.<key>` (or just `<key>` if no section was |
114 | 248 | // passed). Tests set the dictionary in beforeEach so each |
115 | 249 | // test's expectations are isolated. |
116 | 250 | getConfiguration: (section?: string) => ({ |
117 | 251 | get: <T>(key: string, fallback?: T): T => { |
118 | | - const store = |
119 | | - (globalThis as { __stubConfig?: Record<string, unknown> }) |
120 | | - .__stubConfig ?? {}; |
| 252 | + const store = globalThis.__stubConfig ?? {}; |
121 | 253 | const fullKey = section ? `${section}.${key}` : key; |
122 | 254 | if (fullKey in store) return store[fullKey] as T; |
123 | 255 | return fallback as T; |
124 | 256 | }, |
125 | 257 | }), |
126 | 258 | onDidChangeConfiguration: () => ({ dispose: () => undefined }), |
| 259 | + onDidSaveTextDocument: () => ({ dispose: () => undefined }), |
| 260 | + // Resolves with whatever the test stashed on |
| 261 | + // `globalThis.__stubFindFiles` (a flat URI list reused for every |
| 262 | + // include glob) or `globalThis.__stubFindFilesByPattern[include]` |
| 263 | + // (when a per-pattern map is set, more useful for asserting that |
| 264 | + // each pattern is queried separately). Every call is captured on |
| 265 | + // `globalThis.__stubCalls.findFiles` so a test can assert on the |
| 266 | + // include/exclude/maxResults the caller passed. |
| 267 | + findFiles: (include: string, exclude?: string, maxResults?: number) => { |
| 268 | + const calls = ensureCalls(); |
| 269 | + calls.findFiles.push({ include, exclude, maxResults }); |
| 270 | + const byPattern = globalThis.__stubFindFilesByPattern; |
| 271 | + if (byPattern) { |
| 272 | + return Promise.resolve(byPattern[include] ?? []); |
| 273 | + } |
| 274 | + const flat = globalThis.__stubFindFiles; |
| 275 | + return Promise.resolve(flat ?? []); |
| 276 | + }, |
| 277 | + // Resolves with a minimal TextDocument-shaped object for any |
| 278 | + // URI not listed in `__stubOpenTextDocumentFailures`, where it |
| 279 | + // rejects instead. scanWorkspace counts those rejections as |
| 280 | + // `failed` without aborting the rest of the scan. |
| 281 | + openTextDocument: (uri: { toString: () => string }) => { |
| 282 | + const key = uri.toString(); |
| 283 | + if (globalThis.__stubOpenTextDocumentFailures?.has(key)) { |
| 284 | + return Promise.reject(new Error(`stub: open failed for ${key}`)); |
| 285 | + } |
| 286 | + return Promise.resolve({ uri }); |
| 287 | + }, |
127 | 288 | }, |
128 | 289 | languages: { |
129 | 290 | // Two call shapes: |
130 | 291 | // - `getDiagnostics()` returns every [uri, diagnostic[]] pair |
131 | 292 | // - `getDiagnostics(uri)` returns just that uri's diagnostics |
132 | 293 | getDiagnostics: (uri?: { toString: () => string }) => { |
133 | | - const all = |
134 | | - ( |
135 | | - globalThis as { |
136 | | - __stubDiagnostics?: Array<[ |
137 | | - { toString: () => string }, |
138 | | - unknown[], |
139 | | - ]>; |
140 | | - } |
141 | | - ).__stubDiagnostics ?? []; |
| 294 | + const all = globalThis.__stubDiagnostics ?? []; |
142 | 295 | if (uri === undefined) return all; |
143 | 296 | const key = uri.toString(); |
144 | 297 | const match = all.find(([u]) => u.toString() === key); |
145 | 298 | return match ? match[1] : []; |
146 | 299 | }, |
147 | 300 | onDidChangeDiagnostics: () => ({ dispose: () => undefined }), |
148 | 301 | }, |
149 | | - commands: { executeCommand: () => Promise.resolve() }, |
150 | | - window: {}, |
| 302 | + commands: { |
| 303 | + // Captures every executeCommand invocation on the shared |
| 304 | + // `__stubCalls.executeCommand` slot. Tests assert on the call |
| 305 | + // history (e.g. setContext / pipelineCheck.lspReady / true). |
| 306 | + executeCommand: (command: string, ...args: unknown[]) => { |
| 307 | + ensureCalls().executeCommand.push({ command, args }); |
| 308 | + return Promise.resolve(); |
| 309 | + }, |
| 310 | + registerCommand: () => ({ dispose: () => undefined }), |
| 311 | + }, |
| 312 | + env: { |
| 313 | + clipboard: { |
| 314 | + writeText: (text: string) => { |
| 315 | + ensureCalls().clipboardWrites.push({ text }); |
| 316 | + return Promise.resolve(); |
| 317 | + }, |
| 318 | + }, |
| 319 | + openExternal: () => Promise.resolve(true), |
| 320 | + }, |
| 321 | + window: { |
| 322 | + // Terminal factory captures the name and returns a stub whose |
| 323 | + // show/sendText calls land on the shared slot. Each call returns |
| 324 | + // a fresh terminal with its own observation buffer. |
| 325 | + createTerminal: (name: string) => { |
| 326 | + const sent: Array<{ text: string; addNewLine: boolean }> = []; |
| 327 | + const record = { |
| 328 | + name, |
| 329 | + shown: false, |
| 330 | + sent, |
| 331 | + }; |
| 332 | + ensureCalls().terminals.push(record); |
| 333 | + return { |
| 334 | + name, |
| 335 | + show: () => { |
| 336 | + // Mutate the captured record so tests see `shown: true` |
| 337 | + // without having to drill into a closure. |
| 338 | + (record as { shown: boolean }).shown = true; |
| 339 | + }, |
| 340 | + sendText: (text: string, addNewLine?: boolean) => { |
| 341 | + sent.push({ text, addNewLine: addNewLine ?? true }); |
| 342 | + }, |
| 343 | + dispose: () => undefined, |
| 344 | + }; |
| 345 | + }, |
| 346 | + createStatusBarItem: ( |
| 347 | + _alignment?: number, |
| 348 | + _priority?: number, |
| 349 | + ): StubStatusBarItem & { |
| 350 | + show: () => void; |
| 351 | + hide: () => void; |
| 352 | + dispose: () => void; |
| 353 | + } => { |
| 354 | + const item: StubStatusBarItem & { |
| 355 | + show: () => void; |
| 356 | + hide: () => void; |
| 357 | + dispose: () => void; |
| 358 | + } = { |
| 359 | + text: "", |
| 360 | + tooltip: undefined, |
| 361 | + command: undefined, |
| 362 | + name: "", |
| 363 | + backgroundColor: undefined, |
| 364 | + accessibilityInformation: undefined, |
| 365 | + shown: false, |
| 366 | + disposed: false, |
| 367 | + showCount: 0, |
| 368 | + hideCount: 0, |
| 369 | + show() { |
| 370 | + this.shown = true; |
| 371 | + this.showCount += 1; |
| 372 | + }, |
| 373 | + hide() { |
| 374 | + this.shown = false; |
| 375 | + this.hideCount += 1; |
| 376 | + }, |
| 377 | + dispose() { |
| 378 | + this.disposed = true; |
| 379 | + }, |
| 380 | + }; |
| 381 | + ensureCalls().statusBarItems.push(item); |
| 382 | + return item; |
| 383 | + }, |
| 384 | + setStatusBarMessage: (text: string, hideAfterMs?: number) => { |
| 385 | + ensureCalls().statusBarMessages.push({ text, hideAfterMs }); |
| 386 | + return { dispose: () => undefined }; |
| 387 | + }, |
| 388 | + showInformationMessage: (message: string) => { |
| 389 | + ensureCalls().infoMessages.push(message); |
| 390 | + return Promise.resolve(undefined); |
| 391 | + }, |
| 392 | + showWarningMessage: (message: string) => { |
| 393 | + ensureCalls().warningMessages.push(message); |
| 394 | + return Promise.resolve(undefined); |
| 395 | + }, |
| 396 | + showErrorMessage: (message: string) => { |
| 397 | + ensureCalls().errorMessages.push(message); |
| 398 | + return Promise.resolve(undefined); |
| 399 | + }, |
| 400 | + // Progress UI: invokes the task immediately with a no-op |
| 401 | + // `progress` reporter and a never-cancelled token. Good enough |
| 402 | + // to drive scanWorkspace's loop in a unit test without mocking |
| 403 | + // out the full Progress API surface. |
| 404 | + withProgress: async <T>( |
| 405 | + _options: unknown, |
| 406 | + task: ( |
| 407 | + progress: { report: (value: unknown) => void }, |
| 408 | + token: { isCancellationRequested: boolean }, |
| 409 | + ) => Thenable<T>, |
| 410 | + ): Promise<T> => { |
| 411 | + const progress = { report: () => undefined }; |
| 412 | + const token = { |
| 413 | + get isCancellationRequested() { |
| 414 | + return globalThis.__stubProgressCancelled === true; |
| 415 | + }, |
| 416 | + }; |
| 417 | + return task(progress, token); |
| 418 | + }, |
| 419 | + }, |
151 | 420 | }; |
152 | 421 | } |
0 commit comments