|
| 1 | +// Tests for attemptApiRequest abort signal coverage (PR #615) |
| 2 | + |
| 3 | +import { describe, it, expect, vi, beforeEach } from "vitest" |
| 4 | + |
| 5 | +import type { ProviderSettings } from "@roo-code/types" |
| 6 | +import { Task } from "../Task" |
| 7 | +import { ClineProvider } from "../../webview/ClineProvider" |
| 8 | +import { ContextProxy } from "../../config/ContextProxy" |
| 9 | +import * as vscode from "vscode" |
| 10 | + |
| 11 | +// Reuse the same mocks from Task.spec.ts to avoid duplication and missing properties |
| 12 | +vi.mock("delay", () => ({ |
| 13 | + __esModule: true, |
| 14 | + default: vi.fn().mockResolvedValue(undefined), |
| 15 | +})) |
| 16 | + |
| 17 | +vi.mock("vscode", () => { |
| 18 | + // Copy the full vscode mock from the main Task.spec.ts |
| 19 | + const mockDisposable = { dispose: vi.fn() } |
| 20 | + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } |
| 21 | + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } |
| 22 | + const mockTextEditor = { document: mockTextDocument } |
| 23 | + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } |
| 24 | + const mockTabGroup = { tabs: [mockTab] } |
| 25 | + |
| 26 | + return { |
| 27 | + TabInputTextDiff: vi.fn(), |
| 28 | + CodeActionKind: { |
| 29 | + QuickFix: { value: "quickfix" }, |
| 30 | + RefactorRewrite: { value: "refactor.rewrite" }, |
| 31 | + }, |
| 32 | + window: { |
| 33 | + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), |
| 34 | + visibleTextEditors: [mockTextEditor], |
| 35 | + tabGroups: { |
| 36 | + all: [mockTabGroup], |
| 37 | + close: vi.fn(), |
| 38 | + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), |
| 39 | + }, |
| 40 | + showErrorMessage: vi.fn(), |
| 41 | + }, |
| 42 | + workspace: { |
| 43 | + workspaceFolders: [{ uri: { fsPath: "/mock/workspace/path" }, name: "mock-workspace", index: 0 }], |
| 44 | + createFileSystemWatcher: vi.fn(() => ({ |
| 45 | + onDidCreate: vi.fn(() => mockDisposable), |
| 46 | + onDidDelete: vi.fn(() => mockDisposable), |
| 47 | + onDidChange: vi.fn(() => mockDisposable), |
| 48 | + dispose: vi.fn(), |
| 49 | + })), |
| 50 | + fs: { stat: vi.fn().mockResolvedValue({ type: 1 }) }, |
| 51 | + onDidSaveTextDocument: vi.fn(() => mockDisposable), |
| 52 | + getConfiguration: vi.fn(() => ({ get: (_: string, d: any) => d })), |
| 53 | + }, |
| 54 | + env: { uriScheme: "vscode", language: "en" }, |
| 55 | + EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter), |
| 56 | + Disposable: { from: vi.fn() }, |
| 57 | + TabInputText: vi.fn(), |
| 58 | + } |
| 59 | +}) |
| 60 | + |
| 61 | +// Minimal other mocks needed |
| 62 | +vi.mock("../../environment/getEnvironmentDetails", () => ({ |
| 63 | + getEnvironmentDetails: vi.fn().mockResolvedValue(""), |
| 64 | +})) |
| 65 | +vi.mock("../../ignore/RooIgnoreController") |
| 66 | + |
| 67 | +describe("attemptApiRequest abort signal", () => { |
| 68 | + let mockProvider: any |
| 69 | + let mockApiConfig: ProviderSettings |
| 70 | + |
| 71 | + beforeEach(() => { |
| 72 | + const storageUri = { fsPath: "/tmp/test-storage" } |
| 73 | + |
| 74 | + const mockExtensionContext = { |
| 75 | + globalState: { |
| 76 | + get: vi.fn().mockImplementation((key: any) => (key === "taskHistory" ? [] : undefined)), |
| 77 | + update: vi.fn().mockResolvedValue(undefined), |
| 78 | + keys: vi.fn().mockReturnValue([]), |
| 79 | + }, |
| 80 | + globalStorageUri: storageUri, |
| 81 | + workspaceState: { |
| 82 | + get: vi.fn().mockReturnValue(undefined), |
| 83 | + update: vi.fn().mockResolvedValue(undefined), |
| 84 | + keys: vi.fn().mockReturnValue([]), |
| 85 | + }, |
| 86 | + secrets: { |
| 87 | + get: vi.fn().mockResolvedValue(undefined), |
| 88 | + store: vi.fn().mockResolvedValue(undefined), |
| 89 | + delete: vi.fn().mockResolvedValue(undefined), |
| 90 | + }, |
| 91 | + extensionUri: { fsPath: "/mock/extension/path" }, |
| 92 | + extension: { packageJSON: { version: "1.0.0" } }, |
| 93 | + } as unknown as vscode.ExtensionContext |
| 94 | + |
| 95 | + mockProvider = new ClineProvider( |
| 96 | + mockExtensionContext, |
| 97 | + { |
| 98 | + appendLine: vi.fn(), |
| 99 | + append: vi.fn(), |
| 100 | + clear: vi.fn(), |
| 101 | + show: vi.fn(), |
| 102 | + hide: vi.fn(), |
| 103 | + dispose: vi.fn(), |
| 104 | + } as any, |
| 105 | + "sidebar", |
| 106 | + new ContextProxy(mockExtensionContext), |
| 107 | + ) as any |
| 108 | + |
| 109 | + mockApiConfig = { |
| 110 | + apiProvider: "anthropic", |
| 111 | + apiModelId: "claude-3-5-sonnet-20241022", |
| 112 | + apiKey: "test-api-key", |
| 113 | + } as ProviderSettings |
| 114 | + }) |
| 115 | + |
| 116 | + it("sets up AbortController and cleans it up on abort", async () => { |
| 117 | + const task = new Task({ |
| 118 | + provider: mockProvider, |
| 119 | + apiConfiguration: mockApiConfig, |
| 120 | + task: "test task", |
| 121 | + startTask: false, |
| 122 | + }) |
| 123 | + |
| 124 | + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) |
| 125 | + |
| 126 | + // Mock createMessage to return a never-resolving iterator (so we can abort it) |
| 127 | + vi.spyOn(task.api, "createMessage").mockImplementation( |
| 128 | + () => |
| 129 | + ({ |
| 130 | + [Symbol.asyncIterator]: () => ({ |
| 131 | + async next() { |
| 132 | + return new Promise(() => {}) // never resolves |
| 133 | + }, |
| 134 | + }), |
| 135 | + }) as any, |
| 136 | + ) |
| 137 | + |
| 138 | + const gen = (task as any).attemptApiRequest(0) |
| 139 | + |
| 140 | + expect(task.currentRequestAbortController).toBeInstanceOf(AbortController) |
| 141 | + |
| 142 | + // Trigger abort |
| 143 | + task.currentRequestAbortController!.abort() |
| 144 | + |
| 145 | + expect(task.currentRequestAbortController).toBeUndefined() |
| 146 | + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("AbortSignal triggered for current request")) |
| 147 | + |
| 148 | + consoleLogSpy.mockRestore() |
| 149 | + gen.return?.() |
| 150 | + }) |
| 151 | + |
| 152 | + it("rejects immediately if signal is already aborted", async () => { |
| 153 | + const task = new Task({ |
| 154 | + provider: mockProvider, |
| 155 | + apiConfiguration: mockApiConfig, |
| 156 | + task: "test task", |
| 157 | + startTask: false, |
| 158 | + }) |
| 159 | + |
| 160 | + const controller = new AbortController() |
| 161 | + controller.abort() |
| 162 | + |
| 163 | + vi.spyOn(task.api, "createMessage").mockImplementation( |
| 164 | + () => |
| 165 | + ({ |
| 166 | + [Symbol.asyncIterator]: () => ({ async next() {} }), |
| 167 | + }) as any, |
| 168 | + ) |
| 169 | + |
| 170 | + task.currentRequestAbortController = controller |
| 171 | + |
| 172 | + const gen = (task as any).attemptApiRequest(0) |
| 173 | + await expect(gen.next()).rejects.toThrow("Request cancelled by user") |
| 174 | + |
| 175 | + expect(task.currentRequestAbortController).toBeUndefined() |
| 176 | + }) |
| 177 | + |
| 178 | + it("rejects via Promise.race when aborted during first chunk wait", async () => { |
| 179 | + const task = new Task({ |
| 180 | + provider: mockProvider, |
| 181 | + apiConfiguration: mockApiConfig, |
| 182 | + task: "test task", |
| 183 | + startTask: false, |
| 184 | + }) |
| 185 | + |
| 186 | + vi.spyOn(task.api, "createMessage").mockImplementation( |
| 187 | + () => |
| 188 | + ({ |
| 189 | + [Symbol.asyncIterator]: () => ({ |
| 190 | + async next() { |
| 191 | + await new Promise((r) => setTimeout(r, 100)) |
| 192 | + return { value: { type: "text", text: "ok" } } |
| 193 | + }, |
| 194 | + }), |
| 195 | + }) as any, |
| 196 | + ) |
| 197 | + |
| 198 | + const gen = (task as any).attemptApiRequest(0) |
| 199 | + |
| 200 | + // Abort right after controller is created |
| 201 | + setTimeout(() => { |
| 202 | + task.currentRequestAbortController?.abort() |
| 203 | + }, 10) |
| 204 | + |
| 205 | + await expect(gen.next()).rejects.toThrow("Request cancelled by user") |
| 206 | + expect(task.currentRequestAbortController).toBeUndefined() |
| 207 | + }) |
| 208 | +}) |
0 commit comments