From fa8e783aa644a83cf0aaed964640254558615a24 Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Fri, 24 Jul 2026 16:42:50 -0700 Subject: [PATCH 1/2] Move shared HTTP client into extension-toolkit Both the mssql and sql-database-projects extensions carried near-identical copies of the HTTP client, proxy resolution and file download logic. This consolidates them into a single implementation owned by extension-toolkit. - Add `extension-toolkit/base` HttpClient: portable HTTP + download with proxy/tunnel support, a generic `onProgress` callback and typed `IHttpResponse` / `IDownloadFileResult` results. - Add `extension-toolkit/vscode` VscodeHttpClient adapter wiring VS Code's `http.proxy` / `http.proxyStrictSSL` settings and warning messages. - Add `extension-toolkit/base/common/errors.ts` with `getErrorMessage`, so the client no longer needs a formatter injected by callers. - Keep axios out of the public API; the toolkit owns its own response types. - Localized strings stay in the extensions and are injected as `messages`. - Move axios/tunnel dependencies to the toolkit and drop the duplicated http/ folders and tests from both extensions. --- extensions/mssql/package-lock.json | 98 +--- extensions/mssql/package.json | 3 - .../mssql/src/azure/msal/msalAzureAuth.ts | 9 +- extensions/mssql/src/azure/utils.ts | 7 +- .../mssql/src/controllers/mainController.ts | 7 +- extensions/mssql/src/fabric/fabricHelper.ts | 22 +- .../src/languageservice/downloadHelper.ts | 88 +-- extensions/mssql/test/unit/extension.test.ts | 4 +- .../mssql/test/unit/fabricHelper.test.ts | 81 +-- extensions/mssql/test/unit/httpClient.test.ts | 180 +++--- .../mssql/test/unit/languageService.test.ts | 23 +- .../mssql/test/unit/mainController.test.ts | 7 +- .../sql-database-projects/package-lock.json | 105 +--- extensions/sql-database-projects/package.json | 3 - .../src/common/logger.ts | 2 +- .../src/controllers/mainController.ts | 6 +- .../src/http/httpClient.ts | 101 ---- .../src/http/httpClientCore.ts | 503 ----------------- .../src/tools/buildHelper.ts | 36 +- .../test/buildHelper.test.ts | 8 +- .../test/httpClient.test.ts | 531 ------------------ packages/extension-toolkit/package-lock.json | 355 ++++++++++++ packages/extension-toolkit/package.json | 5 + .../src/base/common/errors.ts | 17 + .../src/base/common/index.ts | 6 + .../src/base/http/httpClient.ts | 442 +++++++++------ .../extension-toolkit/src/base/http/index.ts | 6 + packages/extension-toolkit/src/base/index.ts | 2 + .../src/vscode/http/index.ts | 6 + .../src/vscode/http/vscodeHttpClient.ts | 45 +- .../extension-toolkit/src/vscode/index.ts | 1 + 31 files changed, 978 insertions(+), 1731 deletions(-) delete mode 100644 extensions/sql-database-projects/src/http/httpClient.ts delete mode 100644 extensions/sql-database-projects/src/http/httpClientCore.ts delete mode 100644 extensions/sql-database-projects/test/httpClient.test.ts create mode 100644 packages/extension-toolkit/src/base/common/errors.ts create mode 100644 packages/extension-toolkit/src/base/common/index.ts rename extensions/mssql/src/http/httpClientCore.ts => packages/extension-toolkit/src/base/http/httpClient.ts (59%) create mode 100644 packages/extension-toolkit/src/base/http/index.ts create mode 100644 packages/extension-toolkit/src/vscode/http/index.ts rename extensions/mssql/src/http/httpClient.ts => packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts (50%) diff --git a/extensions/mssql/package-lock.json b/extensions/mssql/package-lock.json index d2df51e6d5..9b28cdf95d 100644 --- a/extensions/mssql/package-lock.json +++ b/extensions/mssql/package-lock.json @@ -22,7 +22,6 @@ "@microsoft/vscode-azext-azureauth": "^5.1.1", "@microsoft/vscode-azureresources-api": "^3.1.1", "@xmldom/xmldom": "^0.9.10", - "axios": "^1.18.0", "dockerode": "^4.0.9", "dotenv": "^16.4.5", "error-ex": "^1.3.0", @@ -42,7 +41,6 @@ "strip-json-comments": "^5.0.1", "tar": "^7.5.16", "tmp": "^0.2.7", - "tunnel": "0.0.6", "vscode-jsonrpc": "^9.0.0", "vscode-languageclient": "10.0.0", "xml-formatter": "^3.6.7", @@ -73,7 +71,6 @@ "@types/sinon": "^10.0.12", "@types/sinon-chai": "^4.0.0", "@types/tmp": "0.0.28", - "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", "@types/vscode-webview": "^1.57.5", "@types/xmldom": "^0.1.34", @@ -119,8 +116,13 @@ "../../packages/extension-toolkit": { "version": "0.0.1", "license": "SEE LICENSE IN ../../LICENSE.txt", + "dependencies": { + "axios": "^1.18.0", + "tunnel": "0.0.6" + }, "devDependencies": { "@types/node": "^22.13.14", + "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", "typescript": "^5.8.3" } @@ -4214,14 +4216,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tunnel": { - "version": "0.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/unist": { "version": "3.0.3", "dev": true, @@ -5244,6 +5238,7 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "dev": true, "license": "MIT" }, "node_modules/autocompleter": { @@ -5267,43 +5262,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/azdataGraph": { "version": "0.0.139", "resolved": "git+ssh://git@github.com/Microsoft/azdataGraph.git#0a3ea27e4e2b10274d9a141ddb48cb76b461216a", @@ -5981,6 +5939,7 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6424,6 +6383,7 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6775,6 +6735,7 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6789,6 +6750,7 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6799,6 +6761,7 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7351,26 +7314,6 @@ "flat": "cli.js" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "dev": true, @@ -7403,6 +7346,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7521,6 +7465,7 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7551,6 +7496,7 @@ }, "node_modules/get-proto": { "version": "1.0.1", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7739,6 +7685,7 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7749,6 +7696,7 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7779,6 +7727,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9115,6 +9064,7 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9772,6 +9722,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9779,6 +9730,7 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -11360,15 +11312,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pseudo-localization": { "version": "2.4.0", "dev": true, @@ -13328,6 +13271,7 @@ }, "node_modules/tunnel": { "version": "0.0.6", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index eb1f273d7b..8e3ea837a8 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -105,7 +105,6 @@ "@types/sinon": "^10.0.12", "@types/sinon-chai": "^4.0.0", "@types/tmp": "0.0.28", - "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", "@types/vscode-webview": "^1.57.5", "@types/xmldom": "^0.1.34", @@ -158,7 +157,6 @@ "@microsoft/vscode-azext-azureauth": "^5.1.1", "@microsoft/vscode-azureresources-api": "^3.1.1", "@xmldom/xmldom": "^0.9.10", - "axios": "^1.18.0", "dockerode": "^4.0.9", "dotenv": "^16.4.5", "error-ex": "^1.3.0", @@ -178,7 +176,6 @@ "strip-json-comments": "^5.0.1", "tar": "^7.5.16", "tmp": "^0.2.7", - "tunnel": "0.0.6", "vscode-jsonrpc": "^9.0.0", "vscode-languageclient": "10.0.0", "xml-formatter": "^3.6.7", diff --git a/extensions/mssql/src/azure/msal/msalAzureAuth.ts b/extensions/mssql/src/azure/msal/msalAzureAuth.ts index b7c79ae5e4..cc79ed087f 100644 --- a/extensions/mssql/src/azure/msal/msalAzureAuth.ts +++ b/extensions/mssql/src/azure/msal/msalAzureAuth.ts @@ -28,7 +28,7 @@ import { ILogger } from "../../sharedInterfaces/logger"; import { AzureAuthError } from "../azureAuthError"; import * as Constants from "../constants"; import { ErrorResponseBody } from "@azure/arm-subscriptions"; -import { HttpClient } from "../../http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import { getErrorMessage } from "../../utils/utils"; export type GetTenantsResponseData = { @@ -43,7 +43,7 @@ export abstract class MsalAzureAuth { protected readonly scopesString: string; protected readonly clientId: string; protected readonly resources: Resource[]; - private readonly _httpHelper: HttpClient; + private readonly _httpHelper: VscodeHttpClient; constructor( protected readonly providerSettings: IProviderSettings, @@ -59,7 +59,10 @@ export abstract class MsalAzureAuth { this.scopes = [...this.providerSettings.scopes]; this.scopesString = this.scopes.join(" "); - this._httpHelper = new HttpClient(logger); + this._httpHelper = new VscodeHttpClient({ + logger, + messages: LocalizedConstants.Proxy, + }); } public async startLogin(): Promise { diff --git a/extensions/mssql/src/azure/utils.ts b/extensions/mssql/src/azure/utils.ts index 8ddf6dc2c1..bc42c0774d 100644 --- a/extensions/mssql/src/azure/utils.ts +++ b/extensions/mssql/src/azure/utils.ts @@ -10,10 +10,11 @@ import { PagedAsyncIterableIterator } from "@azure/core-paging"; import * as path from "path"; import * as os from "os"; import * as vscode from "vscode"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import { AzureAuthType, IToken, UserGroup } from "../models/contracts/azure"; +import * as LocalizedConstants from "../constants/locConstants"; import * as Constants from "./constants"; import { TokenCredentialWrapper } from "./credentialWrapper"; -import { HttpClient } from "../http/httpClient"; import { getLogger } from "../models/logger"; import { getErrorMessage } from "../utils/utils"; @@ -121,7 +122,9 @@ export function getAppDataPath(): string { export async function fetchUserGroups(userId: string): Promise { const graphBaseUri = vscode.Uri.parse("https://graph.microsoft.com/v1.0/"); const uri = vscode.Uri.joinPath(graphBaseUri, `users/${userId}/memberOf`); - const httpHelper = new HttpClient(); + const httpHelper = new VscodeHttpClient({ + messages: LocalizedConstants.Proxy, + }); const session = await vscode.authentication.getSession("microsoft", [], { createIfNone: true, diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index 9158253e29..647c83f336 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -118,7 +118,7 @@ import { SearchDatabaseWebViewController } from "../searchDatabase/searchDatabas import { ChangelogWebviewController } from "./changelogWebviewController"; import { AzureDataStudioMigrationWebviewController } from "./azureDataStudioMigrationWebviewController"; import { ShortcutsConfigurationWebviewController } from "./shortcutsConfigurationWebviewController"; -import { HttpClient } from "../http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import { ILogger } from "../sharedInterfaces/logger"; import { logger } from "../models/logger"; import { FileBrowserService } from "../services/fileBrowserService"; @@ -202,7 +202,10 @@ export default class MainController implements vscode.Disposable { this.configuration = vscode.workspace.getConfiguration(); UserSurvey.createInstance(this._context); - new HttpClient(this._logger).warnOnInvalidProxySettings(); + new VscodeHttpClient({ + logger: this._logger, + messages: LocalizedConstants.Proxy, + }).warnOnInvalidProxySettings(); } /** diff --git a/extensions/mssql/src/fabric/fabricHelper.ts b/extensions/mssql/src/fabric/fabricHelper.ts index 5fd545266d..eb1ac35ecd 100644 --- a/extensions/mssql/src/fabric/fabricHelper.ts +++ b/extensions/mssql/src/fabric/fabricHelper.ts @@ -16,10 +16,9 @@ import { IWarehouseArtifact, IWorkspaceRoleAssignment, } from "../sharedInterfaces/fabric"; -import { HttpClient } from "../http/httpClient"; -import { AxiosResponse } from "axios"; +import { IHttpResponse, VscodeHttpClient } from "extension-toolkit/vscode"; import { getErrorMessage } from "../utils/utils"; -import { Fabric as Loc } from "../constants/locConstants"; +import { Fabric as Loc, Proxy } from "../constants/locConstants"; import { getCloudProviderSettings } from "../azure/providerSettings"; import { ILogger } from "../sharedInterfaces/logger"; import { logger } from "../models/logger"; @@ -372,7 +371,10 @@ export class FabricHelper { ): Promise { const uri = vscode.Uri.joinPath(this.getFabricApiUriBase(), api); const fabricLogger = FabricHelper.getFabricLogger(); - const httpHelper = new HttpClient(fabricLogger); + const httpHelper = new VscodeHttpClient({ + logger: fabricLogger, + messages: Proxy, + }); const session = await this.createScopedFabricSession(tenantId, reason); let token = session?.accessToken; @@ -398,7 +400,10 @@ export class FabricHelper { ): Promise { const uri = vscode.Uri.joinPath(this.getFabricApiUriBase(), api); const fabricLogger = FabricHelper.getFabricLogger(); - const httpHelper = new HttpClient(fabricLogger); + const httpHelper = new VscodeHttpClient({ + logger: fabricLogger, + messages: Proxy, + }); const session = await this.createScopedFabricSession(tenantId, reason, scopes); const token = session?.accessToken; @@ -436,14 +441,13 @@ export class FabricHelper { private static async handleLongRunningOperation( retryAfter: string, location: string, - httpHelper: HttpClient, + httpHelper: VscodeHttpClient, fabricLogger: ILogger, token?: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): Promise> { + ): Promise> { const retryAfterInMs = parseInt(retryAfter, 10) || this.defaultRetryInMs; - let longRunningResponse: AxiosResponse | undefined; + let longRunningResponse: IHttpResponse | undefined; while ( !longRunningResponse || longRunningResponse.data.status === IOperationStatus.Running || diff --git a/extensions/mssql/src/languageservice/downloadHelper.ts b/extensions/mssql/src/languageservice/downloadHelper.ts index 565291e1ea..3a7917e829 100644 --- a/extensions/mssql/src/languageservice/downloadHelper.ts +++ b/extensions/mssql/src/languageservice/downloadHelper.ts @@ -3,8 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { + HttpClient, + HttpDownloadError, + IDownloadFileResult, + IDownloadProgress, +} from "extension-toolkit/base"; import { ILogger } from "../sharedInterfaces/logger"; -import { HttpDownloadError, HttpClientCore, IDownloadFileResult } from "../http/httpClientCore"; import { IPackage, IStatusView, PackageError } from "./interfaces"; /* @@ -33,30 +38,20 @@ export default class DownloadHelper { throw new PackageError("Temporary package file unavailable", pkg); } - const progress: IDownloadProgress = { - packageSize: 0, + const progressState: IDownloadProgressState = { dots: 0, - downloadedBytes: 0, downloadPercentage: 0, }; - const httpHelper = new HttpClientCore(logger); + const httpHelper = new HttpClient(logger); try { const result: IDownloadFileResult = await httpHelper.downloadFile( urlString, pkg.tmpFile.fd, { - onHeaders: (headers) => { - progress.packageSize = this.getPackageSize(headers["content-length"]); - if (progress.packageSize > 0) { - logger.debug( - `Package size: ${this.formatBytes(progress.packageSize)} (${Math.ceil(progress.packageSize / 1024)} KB)`, - ); - } - }, - onData: (data) => { - this.handleDataReceivedEvent(progress, data, logger, statusView); + onProgress: (progress) => { + this.handleDownloadProgress(progress, progressState, logger, statusView); }, }, ); @@ -87,45 +82,43 @@ export default class DownloadHelper { /* * Calculate the download percentage and stores in the progress object */ - public handleDataReceivedEvent( + public handleDownloadProgress( progress: IDownloadProgress, - data: Buffer, + progressState: IDownloadProgressState, logger: ILogger, statusView: IStatusView, ): void { - progress.downloadedBytes += data.length; - - // Update status bar item with percentage - if (progress.packageSize > 0) { - let newPercentage = Math.min( - 100, - Math.ceil(100 * (progress.downloadedBytes / progress.packageSize)), + if (progress.downloadedBytes === 0 && progress.totalBytes !== undefined) { + logger.debug( + `Package size: ${this.formatBytes(progress.totalBytes)} (${Math.ceil(progress.totalBytes / 1024)} KB)`, ); - if (newPercentage !== progress.downloadPercentage) { + } + + if (progress.percentage !== undefined && progress.totalBytes !== undefined) { + const newPercentage = Math.min(100, Math.ceil(progress.percentage)); + if (newPercentage !== progressState.downloadPercentage) { statusView.updateServiceDownloadingProgress(newPercentage); - progress.downloadPercentage = newPercentage; + progressState.downloadPercentage = newPercentage; } - // Emit a readable progress update every 5%. - let newDots = Math.ceil(progress.downloadPercentage / 5); - if (newDots > progress.dots) { - logger.info(this.formatProgressMessage(progress)); - progress.dots = newDots; + const newDots = Math.ceil(progressState.downloadPercentage / 5); + if (newDots > progressState.dots) { + logger.info(this.formatProgressMessage(progress, progressState.downloadPercentage)); + progressState.dots = newDots; } } - return; } - private formatProgressMessage(progress: IDownloadProgress): string { + private formatProgressMessage(progress: IDownloadProgress, percentage: number): string { const totalSteps = 20; - const completedSteps = Math.min(totalSteps, Math.ceil(progress.downloadPercentage / 5)); + const completedSteps = Math.min(totalSteps, Math.ceil(percentage / 5)); const progressBar = `${"#".repeat(completedSteps)}${"-".repeat(totalSteps - completedSteps)}`; const downloadedDisplay = this.formatBytes( - Math.min(progress.downloadedBytes, progress.packageSize), + Math.min(progress.downloadedBytes, progress.totalBytes ?? progress.downloadedBytes), ); - const totalDisplay = this.formatBytes(progress.packageSize); + const totalDisplay = this.formatBytes(progress.totalBytes ?? progress.downloadedBytes); - return `Download progress [${progressBar}] ${progress.downloadPercentage}% (${downloadedDisplay} / ${totalDisplay})`; + return `Download progress [${progressBar}] ${percentage}% (${downloadedDisplay} / ${totalDisplay})`; } private formatBytes(bytes: number): string { @@ -141,31 +134,12 @@ export default class DownloadHelper { const megabytes = kilobytes / 1024; return `${megabytes.toFixed(1)} MB`; } - - private getPackageSize(contentLengthHeader: unknown): number { - if (typeof contentLengthHeader === "number") { - return contentLengthHeader; - } - - if (Array.isArray(contentLengthHeader)) { - return this.getPackageSize(contentLengthHeader[0]); - } - - if (typeof contentLengthHeader !== "string") { - return 0; - } - - const packageSize = parseInt(contentLengthHeader, 10); - return Number.isNaN(packageSize) ? 0 : packageSize; - } } /* * Interface to store the values needed to calculate download percentage */ -export interface IDownloadProgress { - packageSize: number; - downloadedBytes: number; +export interface IDownloadProgressState { downloadPercentage: number; dots: number; } diff --git a/extensions/mssql/test/unit/extension.test.ts b/extensions/mssql/test/unit/extension.test.ts index 5235951ebb..edb293e70e 100644 --- a/extensions/mssql/test/unit/extension.test.ts +++ b/extensions/mssql/test/unit/extension.test.ts @@ -23,7 +23,7 @@ import { RequestType } from "vscode-languageclient"; import { stubExtensionContext } from "./utils"; import { ChangelogWebviewController } from "../../src/controllers/changelogWebviewController"; import * as LocalizationCache from "../../src/controllers/localizationCache"; -import { HttpClient } from "../../src/http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import { UserSurvey } from "../../src/nps/userSurvey"; import SqlToolsServerClient from "../../src/languageservice/serviceclient"; import * as UriOwnershipInitialization from "../../src/uriOwnership/uriOwnershipInitialization"; @@ -87,7 +87,7 @@ suite("Extension API Tests", () => { sandbox.stub(LocalizationCache, "initializeWebviewLocalizationCache").returns(); sandbox.stub(IconUtils, "initialize").returns(); sandbox.stub(UserSurvey, "createInstance").returns(); - sandbox.stub(HttpClient.prototype, "warnOnInvalidProxySettings").returns(); + sandbox.stub(VscodeHttpClient.prototype, "warnOnInvalidProxySettings").returns(); sandbox.stub(MainController.prototype, "activate").resolves(true); const sqlToolsClient: sinon.SinonStubbedInstance = diff --git a/extensions/mssql/test/unit/fabricHelper.test.ts b/extensions/mssql/test/unit/fabricHelper.test.ts index dced4fd3b1..95fbbc3abb 100644 --- a/extensions/mssql/test/unit/fabricHelper.test.ts +++ b/extensions/mssql/test/unit/fabricHelper.test.ts @@ -8,9 +8,8 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as vscode from "vscode"; import sinonChai from "sinon-chai"; -import { AxiosResponse } from "axios"; +import { IHttpResponse, VscodeHttpClient } from "extension-toolkit/vscode"; import { FabricHelper } from "../../src/fabric/fabricHelper"; -import { HttpClient } from "../../src/http/httpClient"; import { ICapacity, IFabricError, @@ -26,7 +25,18 @@ chai.use(sinonChai); suite("FabricHelper", () => { let sandbox: sinon.SinonSandbox; - let mockHttpHelper: sinon.SinonStubbedInstance; + let mockHttpHelper: sinon.SinonStubbedInstance; + + const createHttpResponse = ( + data: TData, + status = 200, + statusText = "OK", + ): IHttpResponse => ({ + data, + status, + statusText, + headers: {}, + }); const mockTenantId = "test-tenant-id"; @@ -59,10 +69,10 @@ suite("FabricHelper", () => { setup(() => { sandbox = sinon.createSandbox(); - mockHttpHelper = sandbox.createStubInstance(HttpClient); + mockHttpHelper = sandbox.createStubInstance(VscodeHttpClient); sandbox - .stub(HttpClient.prototype, "makeGetRequest") + .stub(VscodeHttpClient.prototype, "makeGetRequest") .callsFake(mockHttpHelper.makeGetRequest); sandbox.stub(vscode.authentication, "getSession").resolves(mockAuthSession); @@ -75,13 +85,7 @@ suite("FabricHelper", () => { suite("getFabricCapacities", () => { test("should return list of capacities", async () => { const mockResponse = { value: mockCapacities }; - mockHttpHelper.makeGetRequest.resolves({ - data: mockResponse, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse<{ value: ICapacity[] }>["config"], - } as AxiosResponse<{ value: ICapacity[] }>); + mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricCapacities(mockTenantId); @@ -97,13 +101,9 @@ suite("FabricHelper", () => { errorCode: "CapacityNotFound", message: "Capacity not found", }; - mockHttpHelper.makeGetRequest.resolves({ - data: fabricError, - status: 404, - statusText: "Not Found", - headers: {}, - config: {} as AxiosResponse["config"], - } as AxiosResponse); + mockHttpHelper.makeGetRequest.resolves( + createHttpResponse(fabricError, 404, "Not Found"), + ); try { await FabricHelper.getFabricCapacities(mockTenantId); @@ -149,13 +149,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockWorkspaces }; - mockHttpHelper.makeGetRequest.resolves({ - data: mockResponse, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse<{ value: IWorkspace[] }>["config"], - } as AxiosResponse<{ value: IWorkspace[] }>); + mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricWorkspaces(capacityId); @@ -183,13 +177,7 @@ suite("FabricHelper", () => { }; const mockResponse = { value: mockWorkspace }; - mockHttpHelper.makeGetRequest.resolves({ - data: mockResponse, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse<{ value: IWorkspace }>["config"], - } as AxiosResponse<{ value: IWorkspace }>); + mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricWorkspace("workspace-1", "mock-tenant-id"); @@ -218,13 +206,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockDatabases }; - mockHttpHelper.makeGetRequest.resolves({ - data: mockResponse, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse<{ value: ISqlDbArtifact[] }>["config"], - } as AxiosResponse<{ value: ISqlDbArtifact[] }>); + mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricDatabases( { displayName: "Test Workspace", id: "test-workspace-id" } as IWorkspace, @@ -262,13 +244,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockDatabases }; - mockHttpHelper.makeGetRequest.resolves({ - data: mockResponse, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse<{ value: ISqlEndpointArtifact[] }>["config"], - } as AxiosResponse<{ value: ISqlEndpointArtifact[] }>); + mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricSqlEndpoints( { displayName: "Test Workspace", id: "test-workspace-id" } as IWorkspace, @@ -311,13 +287,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockWarehouses }; - mockHttpHelper.makeGetRequest.resolves({ - data: mockResponse, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse<{ value: IWarehouseArtifact[] }>["config"], - } as AxiosResponse<{ value: IWarehouseArtifact[] }>); + mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricWarehouses( { displayName: "Test Workspace", id: "test-workspace-id" } as IWorkspace, @@ -354,8 +324,7 @@ suite("FabricHelper", () => { status: 404, statusText: "Not Found", headers: {}, - config: {} as AxiosResponse["config"], - } as AxiosResponse); + }); try { await FabricHelper.getFabricWorkspaces(capacityId); diff --git a/extensions/mssql/test/unit/httpClient.test.ts b/extensions/mssql/test/unit/httpClient.test.ts index e7fd64773e..87856aa477 100644 --- a/extensions/mssql/test/unit/httpClient.test.ts +++ b/extensions/mssql/test/unit/httpClient.test.ts @@ -7,12 +7,10 @@ import * as chai from "chai"; import { expect } from "chai"; import sinonChai from "sinon-chai"; import * as sinon from "sinon"; -import * as vscode from "vscode"; import * as fs from "fs"; import { PassThrough, Writable } from "stream"; -import axios, { AxiosResponse } from "axios"; +import { HttpClient, HttpDownloadError } from "extension-toolkit/base"; import * as LocalizedConstants from "../../src/constants/locConstants"; -import { HttpClient, HttpDownloadError } from "../../src/http/httpClient"; import { ILogger } from "../../src/sharedInterfaces/logger"; import { createStubLogger } from "./utils"; @@ -22,12 +20,23 @@ suite("HttpClient tests", () => { let sandbox: sinon.SinonSandbox; let httpClient: HttpClient; let logger: sinon.SinonStubbedInstance; + let getProxyConfig: sinon.SinonStub; + let parseUriScheme: sinon.SinonStub; + let showWarningMessage: sinon.SinonStub; setup(() => { sandbox = sinon.createSandbox(); logger = createStubLogger(sandbox); - httpClient = new HttpClient(logger); + getProxyConfig = sandbox.stub().returns(undefined); + parseUriScheme = sandbox.stub().callsFake((value: string) => new URL(value).protocol); + showWarningMessage = sandbox.stub(); + httpClient = new HttpClient(logger, { + getProxyConfig, + parseUriScheme, + showWarningMessage, + messages: LocalizedConstants.Proxy, + }); }); teardown(() => { @@ -40,15 +49,17 @@ suite("HttpClient tests", () => { const token = "test-token"; const responseData = { value: [{ id: 1, name: "test" }] }; - const mockResponse: AxiosResponse = { + const mockResponse = { data: responseData, status: 200, statusText: "OK", headers: {}, - config: {} as AxiosResponse["config"], }; - const axiosGetStub = sandbox.stub(axios, "get").resolves(mockResponse); + const getStub = sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "get") + .resolves(mockResponse); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ headers: { Authorization: `Bearer ${token}` }, @@ -60,7 +71,7 @@ suite("HttpClient tests", () => { const result = await httpClient.makeGetRequest(requestUrl, token); expect(result).to.deep.equal(mockResponse); - expect(axiosGetStub).to.have.been.calledOnce; + expect(getStub).to.have.been.calledWith(requestUrl, sinon.match.any); }); }); @@ -71,15 +82,17 @@ suite("HttpClient tests", () => { const payload = { name: "new item" }; const responseData = { id: 2, name: "new item" }; - const mockResponse: AxiosResponse = { + const mockResponse = { data: responseData, status: 201, statusText: "Created", headers: {}, - config: {} as AxiosResponse["config"], }; - const axiosPostStub = sandbox.stub(axios, "post").resolves(mockResponse); + const postStub = sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "post") + .resolves(mockResponse); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ headers: { Authorization: `Bearer ${token}` }, @@ -91,7 +104,7 @@ suite("HttpClient tests", () => { const result = await httpClient.makePostRequest(requestUrl, token, payload); expect(result).to.deep.equal(mockResponse); - expect(axiosPostStub).to.have.been.calledWith(requestUrl, payload, sinon.match.any); + expect(postStub).to.have.been.calledWith(requestUrl, payload, sinon.match.any); }); }); @@ -115,7 +128,8 @@ suite("HttpClient tests", () => { }); sandbox - .stub(httpClient, "setupRequest") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "setupRequest") .returns({ requestUrl: normalizedUrl, config: {} }); sandbox @@ -123,23 +137,23 @@ suite("HttpClient tests", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any .returns(tmpFileStream as any); - const mockResponse: AxiosResponse = { + const mockResponse = { data: responseStream, status: 200, statusText: "OK", headers, - config: {} as AxiosResponse["config"], }; - sandbox.stub(axios, "get").resolves(mockResponse); + const getStub = sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "get") + .resolves(mockResponse); - const onHeaders = sandbox.spy(); - const onData = sandbox.spy((data: Buffer) => data.length); + const onProgress = sandbox.spy(); let downloadCompleted = false; const downloadPromise = httpClient .downloadFile(requestUrl, 123, { - onHeaders, - onData, + onProgress, }) .then((result) => { downloadCompleted = true; @@ -160,13 +174,20 @@ suite("HttpClient tests", () => { expect(result.status).to.equal(200); expect(result.headers).to.equal(headers); - expect(onHeaders).to.have.been.calledWithExactly(headers); expect(receivedChunkLengths).to.deep.equal([3, 2]); - expect(onData.args.map((args) => (args[0] as Buffer).length)).to.deep.equal([3, 2]); - expect(axios.get).to.have.been.calledWith( + expect(onProgress.args.map((args) => args[0])).to.deep.equal([ + { downloadedBytes: 0, totalBytes: 5, percentage: 0 }, + { downloadedBytes: 3, totalBytes: 5, percentage: 60 }, + { downloadedBytes: 5, totalBytes: 5, percentage: 100 }, + ]); + expect(getStub).to.have.been.calledWith( normalizedUrl, sinon.match({ responseType: "stream" }), ); + expect(fs.createWriteStream).to.have.been.calledWith( + "", + sinon.match({ fd: 123, autoClose: false }), + ); }); test("should return error code and destroy stream upon HTTP error", async () => { @@ -178,35 +199,64 @@ suite("HttpClient tests", () => { const destroySpy = sandbox.spy(responseStream, "destroy"); sandbox - .stub(httpClient, "setupRequest") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "setupRequest") .returns({ requestUrl: normalizedUrl, config: {} }); - const mockResponse: AxiosResponse = { + const mockResponse = { data: responseStream, status: 404, statusText: "Not Found", headers, - config: {} as AxiosResponse["config"], }; - sandbox.stub(axios, "get").resolves(mockResponse); + sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "get") + .resolves(mockResponse); - const onHeaders = sandbox.spy(); - const result = await httpClient.downloadFile(requestUrl, 123, { onHeaders }); + const onProgress = sandbox.spy(); + const result = await httpClient.downloadFile(requestUrl, 123, { onProgress }); expect(result.status).to.equal(404); expect(result.headers).to.equal(headers); - expect(onHeaders).to.have.been.calledOnceWithExactly(headers); + expect(onProgress).to.have.been.calledWithExactly({ + downloadedBytes: 0, + totalBytes: undefined, + percentage: undefined, + }); expect(destroySpy).to.have.been.calledOnce; }); + test("should open and close path destinations", async () => { + const result = { status: 200, headers: {} }; + const openStub = sandbox.stub(fs, "openSync").returns(123); + const closeStub = sandbox.stub(fs, "closeSync"); + sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "downloadToFileDescriptor") + .resolves(result); + + expect( + await httpClient.downloadFile("https://example.com/file", "target.zip"), + ).to.equal(result); + expect(openStub).to.have.been.calledWith("target.zip", "w"); + expect(closeStub).to.have.been.calledWith(123); + }); + test("should wrap request errors in HttpDownloadError", async () => { const requestUrl = "https://download.example.com/file"; - sandbox.stub(httpClient, "setupRequest").returns({ requestUrl, config: {} }); + sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "setupRequest") + .returns({ requestUrl, config: {} }); const requestError = new Error("network error") as NodeJS.ErrnoException; requestError.code = "ECONNRESET"; - sandbox.stub(axios, "get").rejects(requestError); + sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "get") + .rejects(requestError); try { await httpClient.downloadFile(requestUrl, 123); @@ -223,20 +273,25 @@ suite("HttpClient tests", () => { const responseStream = new PassThrough(); const tmpFileStream = new PassThrough(); - sandbox.stub(httpClient, "setupRequest").returns({ requestUrl, config: {} }); + sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "setupRequest") + .returns({ requestUrl, config: {} }); sandbox .stub(fs, "createWriteStream") // eslint-disable-next-line @typescript-eslint/no-explicit-any .returns(tmpFileStream as any); - const mockResponse: AxiosResponse = { + const mockResponse = { data: responseStream, status: 200, statusText: "OK", headers: {}, - config: {} as AxiosResponse["config"], }; - sandbox.stub(axios, "get").resolves(mockResponse); + sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(httpClient as any, "get") + .resolves(mockResponse); const responseError = new Error("stream failed") as NodeJS.ErrnoException; responseError.code = "EPIPE"; @@ -265,18 +320,11 @@ suite("HttpClient tests", () => { httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); - sandbox - .stub(vscode.Uri, "parse") - .withArgs(invalidProxyValue) - .returns({ scheme: undefined } as vscode.Uri); - - const warningMessageStub = sandbox - .stub(vscode.window, "showWarningMessage") - .resolves(undefined); + parseUriScheme.withArgs(invalidProxyValue).returns(undefined); httpClient.warnOnInvalidProxySettings(); - expect(warningMessageStub).to.have.been.calledOnceWithExactly( + expect(showWarningMessage).to.have.been.calledWithExactly( LocalizedConstants.Proxy.missingProtocolWarning(invalidProxyValue), ); }); @@ -287,15 +335,11 @@ suite("HttpClient tests", () => { httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); const uriError = new Error("invalid uri format"); - sandbox.stub(vscode.Uri, "parse").withArgs(invalidProxyValue).throws(uriError); - - const warningMessageStub = sandbox - .stub(vscode.window, "showWarningMessage") - .resolves(undefined); + parseUriScheme.withArgs(invalidProxyValue).throws(uriError); httpClient.warnOnInvalidProxySettings(); - expect(warningMessageStub).to.have.been.calledOnceWithExactly( + expect(showWarningMessage).to.have.been.calledWithExactly( LocalizedConstants.Proxy.unparseableWarning(invalidProxyValue, uriError.message), ); }); @@ -308,15 +352,13 @@ suite("HttpClient tests", () => { ]; const proxyConfigStub = sandbox.stub(); - const warningMessageSpy = sandbox.stub(vscode.window, "showWarningMessage"); - for (const validProxyValue of validProxyValues) { proxyConfigStub.reset(); httpClient["loadProxyConfig"] = proxyConfigStub.returns(validProxyValue); httpClient.warnOnInvalidProxySettings(); - expect(warningMessageSpy, `Should not warn for valid proxy: ${validProxyValue}`).to + expect(showWarningMessage, `Should not warn for valid proxy: ${validProxyValue}`).to .not.have.been.called; } }); @@ -324,18 +366,13 @@ suite("HttpClient tests", () => { test("Does not warn when proxy is undefined", () => { httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); - const warningMessageSpy = sandbox.stub(vscode.window, "showWarningMessage"); - httpClient.warnOnInvalidProxySettings(); - expect(warningMessageSpy).to.not.have.been.called; + expect(showWarningMessage).to.not.have.been.called; }); test("loadProxyConfig prefers VS Code configuration over environment variables", () => { - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxy: configProxy } as unknown as vscode.WorkspaceConfiguration); + getProxyConfig.returns(configProxy); sandbox.stub(process, "env").value({ HTTP_PROXY: envProxy, @@ -348,11 +385,6 @@ suite("HttpClient tests", () => { }); test("loadProxyConfig falls back to environment variables when config missing", () => { - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxy: undefined } as unknown as vscode.WorkspaceConfiguration); - sandbox.stub(process, "env").value({ HTTP_PROXY: envProxy, }); @@ -409,10 +441,6 @@ suite("HttpClient tests", () => { const proxy = "https://proxy.example.com:8080"; httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxyStrictSSL: true } as unknown as vscode.WorkspaceConfiguration); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(httpClient as any, "createProxyAgent").returns({ @@ -434,10 +462,6 @@ suite("HttpClient tests", () => { const proxy = "http://proxy.example.com:8080"; httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxyStrictSSL: false } as unknown as vscode.WorkspaceConfiguration); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(httpClient as any, "createProxyAgent").returns({ @@ -449,8 +473,8 @@ suite("HttpClient tests", () => { const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); expect(result.proxy).to.be.false; - expect(result.httpAgent).to.exist; - expect(result.httpsAgent).to.be.undefined; + expect(result.httpsAgent).to.exist; + expect(result.httpAgent).to.be.undefined; }); test("should create proxy agent when proxy is found", () => { @@ -459,10 +483,6 @@ suite("HttpClient tests", () => { const proxy = "http://proxy.example.com:8080"; httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxyStrictSSL: false } as unknown as vscode.WorkspaceConfiguration); // eslint-disable-next-line @typescript-eslint/no-explicit-any sandbox.stub(httpClient as any, "createProxyAgent").returns({ diff --git a/extensions/mssql/test/unit/languageService.test.ts b/extensions/mssql/test/unit/languageService.test.ts index c83c47c5b6..57861fb6d9 100644 --- a/extensions/mssql/test/unit/languageService.test.ts +++ b/extensions/mssql/test/unit/languageService.test.ts @@ -5,9 +5,10 @@ import * as sinon from "sinon"; import { expect } from "chai"; +import { IDownloadProgress } from "extension-toolkit/base"; import DecompressProvider from "../../src/languageservice/decompressProvider"; import { IPackage, IStatusView } from "../../src/languageservice/interfaces"; -import DownloadHelper, { IDownloadProgress } from "../../src/languageservice/downloadHelper"; +import DownloadHelper, { IDownloadProgressState } from "../../src/languageservice/downloadHelper"; import { stubILogger } from "./utils"; suite("Language Service Tests", () => { @@ -42,10 +43,13 @@ suite("Language Service Tests", () => { suite("DownloadHelper Tests", () => { let downloadHelper = new DownloadHelper(); - test("handleDataReceivedEvent test", () => { - let mockProgress: IDownloadProgress = { - packageSize: 10, - downloadedBytes: 0, + test("handleDownloadProgress test", () => { + const mockProgress: IDownloadProgress = { + totalBytes: 10, + downloadedBytes: 5, + percentage: 50, + }; + const progressState: IDownloadProgressState = { downloadPercentage: 0, dots: 0, }; @@ -56,15 +60,14 @@ suite("Language Service Tests", () => { serviceInstallationFailed: () => undefined, updateServiceDownloadingProgress: (_downloadPercentage: number) => undefined, }; - downloadHelper.handleDataReceivedEvent( + downloadHelper.handleDownloadProgress( mockProgress, - Buffer.from([1, 2, 3, 4, 5]), + progressState, testLogger, mockStatusView, ); - expect(mockProgress.downloadPercentage).to.equal(50); - expect(mockProgress.downloadedBytes).to.equal(5); - expect(mockProgress.dots).to.equal(10); + expect(progressState.downloadPercentage).to.equal(50); + expect(progressState.dots).to.equal(10); }); }); }); diff --git a/extensions/mssql/test/unit/mainController.test.ts b/extensions/mssql/test/unit/mainController.test.ts index 62a58d61b0..4d63976b07 100644 --- a/extensions/mssql/test/unit/mainController.test.ts +++ b/extensions/mssql/test/unit/mainController.test.ts @@ -12,7 +12,7 @@ import MainController from "../../src/controllers/mainController"; import ConnectionManager from "../../src/controllers/connectionManager"; import { stubTelemetry, stubExtensionContext, stubMessageBoxes } from "./utils"; import * as Constants from "../../src/constants/constants"; -import { HttpClient } from "../../src/http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import * as LocalizedConstants from "../../src/constants/locConstants"; import { SchemaDesignerWebviewManager } from "../../src/schemaDesigner/schemaDesignerWebviewManager"; import { CopilotChat } from "../../src/sharedInterfaces/copilotChat"; @@ -373,7 +373,10 @@ suite("MainController Tests", function () { }); test("Proxy settings are checked on initialization", async () => { - const httpHelperWarnSpy = sandbox.spy(HttpClient.prototype, "warnOnInvalidProxySettings"); + const httpHelperWarnSpy = sandbox.spy( + VscodeHttpClient.prototype, + "warnOnInvalidProxySettings", + ); new MainController(context, connectionManager); diff --git a/extensions/sql-database-projects/package-lock.json b/extensions/sql-database-projects/package-lock.json index 8c806edb1c..e174205236 100644 --- a/extensions/sql-database-projects/package-lock.json +++ b/extensions/sql-database-projects/package-lock.json @@ -12,14 +12,12 @@ "@microsoft/ads-extension-telemetry": "^3.0.1", "@xmldom/xmldom": "^0.9.10", "ajv": "^8.18.0", - "axios": "^1.18.0", "extension-toolkit": "file:../../packages/extension-toolkit", "extract-zip": "^2.0.1", "fs-extra": "^5.0.0", "glob": "^13.0.6", "promisify-child-process": "^3.1.1", "semver": "^7.5.2", - "tunnel": "0.0.6", "vscode-jsonrpc": "^8.2.1", "vscode-languageclient": "5.2.1", "which": "^6.0.1", @@ -33,7 +31,6 @@ "@types/semver": "^7.3.1", "@types/sinon": "^9.0.4", "@types/sinon-chai": "^4.0.0", - "@types/tunnel": "0.0.1", "@types/vscode": "1.98.0", "@types/which": "^3.0.4", "@types/xml-formatter": "^1.1.0", @@ -65,8 +62,13 @@ "../../packages/extension-toolkit": { "version": "0.0.1", "license": "SEE LICENSE IN ../../LICENSE.txt", + "dependencies": { + "axios": "^1.18.0", + "tunnel": "0.0.6" + }, "devDependencies": { "@types/node": "^22.13.14", + "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", "typescript": "^5.8.3" } @@ -2040,16 +2042,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tunnel": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.1.tgz", - "integrity": "sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/vscode": { "version": "1.98.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.98.0.tgz", @@ -3093,6 +3085,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -3111,43 +3104,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -3492,6 +3448,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3823,6 +3780,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4175,6 +4133,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4264,6 +4223,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4468,6 +4428,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4477,6 +4438,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4486,6 +4448,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4498,6 +4461,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5157,26 +5121,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5214,6 +5158,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -5270,6 +5215,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5353,6 +5299,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5387,6 +5334,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5545,6 +5493,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5615,6 +5564,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5627,6 +5577,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5659,6 +5610,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7018,6 +6970,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7092,6 +7045,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7101,6 +7055,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8790,15 +8745,6 @@ "@babel/runtime": "^7.1.5" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pseudo-localization": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/pseudo-localization/-/pseudo-localization-2.4.0.tgz", @@ -10510,6 +10456,7 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6.11 <=0.7.0 || >=0.7.3" diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 641712e791..1ea996c710 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -588,14 +588,12 @@ "@microsoft/ads-extension-telemetry": "^3.0.1", "@xmldom/xmldom": "^0.9.10", "ajv": "^8.18.0", - "axios": "^1.18.0", "extension-toolkit": "file:../../packages/extension-toolkit", "extract-zip": "^2.0.1", "fs-extra": "^5.0.0", "glob": "^13.0.6", "promisify-child-process": "^3.1.1", "semver": "^7.5.2", - "tunnel": "0.0.6", "vscode-jsonrpc": "^8.2.1", "vscode-languageclient": "5.2.1", "which": "^6.0.1", @@ -609,7 +607,6 @@ "@types/semver": "^7.3.1", "@types/sinon": "^9.0.4", "@types/sinon-chai": "^4.0.0", - "@types/tunnel": "0.0.1", "@types/vscode": "1.98.0", "@types/which": "^3.0.4", "@types/xml-formatter": "^1.1.0", diff --git a/extensions/sql-database-projects/src/common/logger.ts b/extensions/sql-database-projects/src/common/logger.ts index c73778f634..0a83484197 100644 --- a/extensions/sql-database-projects/src/common/logger.ts +++ b/extensions/sql-database-projects/src/common/logger.ts @@ -46,7 +46,7 @@ export enum LogLevel { /** * Logger that writes formatted, timestamped messages to a VS Code OutputChannel. - * Implements ILogger so it can be passed directly to HttpClient / HttpClientCore. + * Implements the logger contract used by the shared HttpClient. * * During build operations the log level is always set to Verbose so that proxy * diagnostic messages are visible in the "Database Projects" output channel. diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index f1534409e5..03c8daf9d0 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -22,7 +22,7 @@ import * as constants from "../common/constants"; import { SqlDatabaseProjectProvider } from "../projectProvider/projectProvider"; import { ItemType } from "../sqldbproj"; import { FileNode } from "../models/tree/fileFolderTreeItem"; -import { HttpClient } from "../http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; /** * The main controller class that initializes the extension @@ -64,7 +64,9 @@ export default class MainController implements vscode.Disposable { } // Warn about invalid proxy settings early during activation - new HttpClient().warnOnInvalidProxySettings(); + new VscodeHttpClient({ + messages: constants.Proxy, + }).warnOnInvalidProxySettings(); await this.initializeDatabaseProjects(); return new SqlDatabaseProjectProvider(this.projectsController); diff --git a/extensions/sql-database-projects/src/http/httpClient.ts b/extensions/sql-database-projects/src/http/httpClient.ts deleted file mode 100644 index 087b167d1f..0000000000 --- a/extensions/sql-database-projects/src/http/httpClient.ts +++ /dev/null @@ -1,101 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from "vscode"; -import * as fs from "fs"; -import { - HttpClientCore, - IHttpClientDependencies, - HttpDownloadError, - IDownloadFileOptions, - IDownloadFileResult, -} from "./httpClientCore"; -import { ILogger } from "../common/logger"; -import * as constants from "../common/constants"; -import { getErrorMessage } from "../common/utils"; - -export class HttpClient extends HttpClientCore { - constructor(logger?: ILogger) { - const dependencies: IHttpClientDependencies = { - getProxyConfig: () => - vscode.workspace.getConfiguration("http")["proxy"] as string | undefined, - getProxyStrictSSL: () => - vscode.workspace.getConfiguration("http")["proxyStrictSSL"] as boolean | undefined, - - parseUriScheme: (value: string) => vscode.Uri.parse(value).scheme, - showWarningMessage: (message: string) => { - void vscode.window.showWarningMessage(message); - }, - getErrorMessage, - // Provide a messages implementation so that HttpClientCore can surface - // proxy warnings via showWarningMessage. Without this, warnOnInvalidProxySettings - // would silently skip the showWarningMessage call even though it is wired up above. - messages: { - missingProtocolWarning: constants.Proxy.missingProtocolWarning, - unparseableWarning: constants.Proxy.unparseableWarning, - unableToGetProxyAgentOptions: constants.Proxy.unableToGetProxyAgentOptions, - }, - }; - super(logger, dependencies); - } - - /** - * Downloads a file from downloadUrl and writes it to targetPath (path-based wrapper over downloadFile). - * Used by build tooling (buildHelper.ts) which works with file paths rather than file descriptors. - */ - public async download( - downloadUrl: string, - targetPath: string, - outputChannel?: vscode.OutputChannel, - ): Promise { - const fd = fs.openSync(targetPath, "w"); - - let totalMB: number | undefined; - let receivedBytes = 0; - let printThreshold = 0.1; - - const options: IDownloadFileOptions = { - onHeaders: (headers) => { - const totalBytes = parseInt((headers["content-length"] as string) || "0"); - totalMB = totalBytes > 0 ? totalBytes / (1024 * 1024) : undefined; - if (totalMB !== undefined) { - outputChannel?.appendLine( - `${constants.downloading} ${downloadUrl} (0 / ${totalMB.toFixed(2)} MB)`, - ); - } - }, - onData: (chunk: Buffer) => { - receivedBytes += chunk.length; - if (totalMB) { - const receivedMB = receivedBytes / (1024 * 1024); - if (receivedMB / totalMB >= printThreshold) { - outputChannel?.appendLine( - `${constants.downloadProgress} (${receivedMB.toFixed(2)} / ${totalMB.toFixed(2)} MB)`, - ); - printThreshold += 0.1; - } - } - }, - }; - - let result: IDownloadFileResult; - try { - result = await this.downloadFile(downloadUrl, fd, options); - } catch (e) { - outputChannel?.appendLine(`${constants.downloadError}: ${getErrorMessage(e)}`); - throw e; - } finally { - fs.closeSync(fd); - } - - if (result.status !== 200) { - const message = `HTTP ${result.status}`; - outputChannel?.appendLine(`${constants.downloadError}: ${message}`); - throw new Error(message); - } - } -} - -export { ILogger, HttpDownloadError, IDownloadFileOptions, IDownloadFileResult }; diff --git a/extensions/sql-database-projects/src/http/httpClientCore.ts b/extensions/sql-database-projects/src/http/httpClientCore.ts deleted file mode 100644 index f1e0520841..0000000000 --- a/extensions/sql-database-projects/src/http/httpClientCore.ts +++ /dev/null @@ -1,503 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// NOTE: This file should always be kept in sync with the equivalent in the MSSQL extension: -// extensions/mssql/src/http/httpClientCore.ts - -import * as tunnel from "tunnel"; -import * as http from "http"; -import * as https from "https"; -import * as fs from "fs"; -import axios, { AxiosRequestConfig, AxiosResponse, RawAxiosResponseHeaders } from "axios"; -import { Readable } from "stream"; -import { ILogger } from "../common/logger"; - -const UnableToGetProxyAgentOptionsMessage = "Unable to read proxy agent options."; -const HTTPS_PORT = 443; -const HTTP_PORT = 80; - -export interface IHttpClientMessages { - missingProtocolWarning(proxy: string): string; - unparseableWarning(proxy: string, errorMessage: string): string; - unableToGetProxyAgentOptions: string; -} - -export interface IHttpClientDependencies { - getProxyConfig?: () => string | undefined; - getProxyStrictSSL?: () => boolean | undefined; - parseUriScheme?: (value: string) => string | undefined; - showWarningMessage?: (message: string) => void; - getErrorMessage?: (error: unknown) => string; - messages?: IHttpClientMessages; -} - -/** - * Core HTTP client class that is independent of VS Code APIs and can be used in any context, like the build/pipeline infrastructure. - * The HttpClient class extends this core class and provides VS Code specific implementations of the dependencies. - */ -export class HttpClientCore { - constructor( - protected readonly logger?: ILogger, - private readonly dependencies: IHttpClientDependencies = {}, - ) {} - - /** - * Makes a GET request to the specified URL with the provided token. - */ - public async makeGetRequest( - requestUrl: string, - token: string, - ): Promise> { - const request = this.setupRequest(requestUrl, token); - - const response: AxiosResponse = await axios.get( - request.requestUrl, - request.config, - ); - this.logger?.piiSanitized( - "GET request ", - [ - { - name: "response", - objOrArray: - (response.data?.value as TResponse) ?? - (response.data as { value: TResponse }), - }, - ], - [], - request.requestUrl, - ); - return response; - } - - /** - * Makes a POST request to the specified URL with the provided token and payload. - */ - public async makePostRequest( - requestUrl: string, - token: string, - payload: TPayload, - ): Promise> { - const request = this.setupRequest(requestUrl, token); - - const response: AxiosResponse = await axios.post( - request.requestUrl, - payload, - request.config, - ); - this.logger?.piiSanitized( - "POST request ", - [{ name: "response", objOrArray: response.data }], - [], - request.requestUrl, - ); - return response; - } - - /** - * Downloads a file from the specified URL to the destination file descriptor, with optional callbacks for headers and data received. - * @param requestUrl request URL to download the file from - * @param destinationFd file descriptor of the destination file to write the downloaded content to - * @param options optional callbacks for headers and data received - * @returns result of the download operation, including the HTTP status and response headers - */ - public async downloadFile( - requestUrl: string, - destinationFd: number, - options?: IDownloadFileOptions, - ): Promise { - const request = this.setupRequest(requestUrl); - const requestConfig: AxiosRequestConfig = { - ...request.config, - responseType: "stream", - }; - - let response: AxiosResponse; - try { - response = await axios.get(request.requestUrl, requestConfig); - } catch (error: unknown) { - throw new HttpDownloadError("request", error as NodeJS.ErrnoException); - } - - options?.onHeaders?.(response.headers); - if (response.status !== 200) { - response.data.destroy(); - return { - status: response.status, - headers: response.headers, - }; - } - - await new Promise((resolve, reject) => { - // autoClose: false keeps fd ownership with the caller (who calls fs.closeSync in finally). - // Without it, pipe's automatic end() would trigger auto-close of the fd, - // causing the caller's fs.closeSync to throw EBADF. - const tmpFile = fs.createWriteStream("", { fd: destinationFd, autoClose: false }); - - const cleanup = (err: NodeJS.ErrnoException) => { - // Destroy both streams to avoid file-descriptor leaks and stalled pipes - response.data.destroy(); - tmpFile.destroy(); - reject(new HttpDownloadError("response", err)); - }; - - response.data.on("data", (data: Buffer) => { - options?.onData?.(data); - }); - - response.data.on("error", cleanup); - tmpFile.on("error", cleanup); - - // Resolve only after the WriteStream has fully flushed to disk. - // pipe automatically calls tmpFile.end() when the response stream finishes, - // which triggers the 'finish' event once all bytes have been written. - tmpFile.on("finish", resolve); - - response.data.pipe(tmpFile); - }); - - return { - status: response.status, - headers: response.headers, - }; - } - - public warnOnInvalidProxySettings(): void { - const proxy = this.loadProxyConfig(); - if (!proxy) { - return; - } - - let message = undefined; - let localizedMessage = undefined; - - try { - const scheme = this.dependencies.parseUriScheme - ? this.dependencies.parseUriScheme(proxy) - : new URL(proxy).protocol; - - if (!scheme) { - message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using this extension.`; - localizedMessage = this.dependencies.messages?.missingProtocolWarning(proxy); - } - } catch (err) { - const errorMessage = this.getErrorMessage(err); - message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using this extension. Error: ${errorMessage}`; - localizedMessage = this.dependencies.messages?.unparseableWarning(proxy, errorMessage); - } - - if (message) { - if (localizedMessage) { - this.dependencies.showWarningMessage?.(localizedMessage); - } - this.logger?.warn(message); - } - } - - /** - * Sets up the request URL and Axios request configuration, including headers and proxy/agent settings, based on the provided URL and token. - * Public for testing purposes. - */ - public setupRequest( - requestUrl: string, - token?: string, - ): { requestUrl: string; config: AxiosRequestConfig } { - const config = this.setupConfigAndProxyForRequest(requestUrl, token); - return { - requestUrl: this.constructRequestUrl(requestUrl, config), - config, - }; - } - - /** - * Builds an Axios request config with headers, auth token, and proxy/agent settings. - * - * - Adds JSON content type and bearer token headers. - * - Disables Axios status throwing (`validateStatus` always true). - * - Checks VS Code HTTP proxy settings or environment variables. - * - If a proxy is found, disables Axios' default proxy handling - * and attaches a custom HTTP/HTTPS agent. - * - * @param requestUrl - The target request URL. - * @param token - Bearer token for the Authorization header. - * @returns AxiosRequestConfig with headers and proxy/agent configuration. - */ - private setupConfigAndProxyForRequest(requestUrl: string, token?: string): AxiosRequestConfig { - const headers: { "Content-Type": string; Authorization?: string } = { - "Content-Type": "application/json", - }; - - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - const config: AxiosRequestConfig = { - headers, - validateStatus: () => true, // Never throw - }; - - const proxy = this.loadProxyConfig(); - - if (proxy) { - this.logger?.verbose( - "Proxy endpoint found in environment variables or workspace configuration.", - ); - - // Turning off automatic proxy detection to avoid issues with tunneling agent by setting proxy to false. - // https://github.com/axios/axios/blob/bad6d8b97b52c0c15311c92dd596fc0bff122651/lib/adapters/http.js#L85 - config.proxy = false; - - const agent = this.createProxyAgent( - requestUrl, - proxy, - this.dependencies.getProxyStrictSSL?.(), - ); - // Axios selects the agent based on the *request* URL scheme, not the proxy scheme. - // An HTTPS request must use httpsAgent even when routed through an HTTP proxy. - if (requestUrl.startsWith("https")) { - config.httpsAgent = agent.agent; - } else { - config.httpAgent = agent.agent; - } - } - return config; - } - - /** - * Attempts to read proxy configuration in priority order: - * 1. VS Code settings (http.proxy config key) - * 2. environment variables (HTTP_PROXY, then HTTPS_PROXY) - * @returns found proxy information - */ - private loadProxyConfig(): string | undefined { - let proxy: string | undefined = this.dependencies.getProxyConfig?.(); - - if (!proxy) { - this.logger?.verbose( - "Workspace HTTP config didn't contain a proxy endpoint. Checking environment variables.", - ); - proxy = this.loadEnvironmentProxyValue(); - } - - return proxy; - } - - /** - * Constructs a request URL that explicitly includes the port number. - * - * When a proxy is configured, `setupConfigAndProxyForRequest` sets `config.proxy = false` (to - * disable Axios's built-in proxy auto-detection in favour of a tunneling agent). In that case, - * and when no proxy is involved at all (`config.proxy === undefined`), the explicit port is - * added to the URL so that Axios does not fall back to port 80 when routing through an HTTP - * proxy. The original URL is returned unchanged only when `config.proxy` is a truthy - * `AxiosProxyConfig` object, which does not occur in the current code paths. - * - * @param requestUrl - The original request URL. - * @param config - The Axios request configuration, which may contain proxy settings. - * @returns A URL string with the explicit port included, or the original URL when - * `config.proxy` is a truthy proxy config object. - */ - private constructRequestUrl(requestUrl: string, config: AxiosRequestConfig): string { - if (!config.proxy) { - // Request URL will include HTTPS port 443 ('https://management.azure.com:443/tenants?api-version=2019-11-01'), so - // that Axios doesn't try to reach this URL with HTTP port 80 on HTTP proxies, which result in an error. See https://github.com/axios/axios/issues/925 - - const parsedRequestUrl = new URL(requestUrl); - // Preserve explicitly-specified ports (e.g., https://host:8443/...), only inject default when no port was provided - const port = - parsedRequestUrl.port || - (parsedRequestUrl.protocol?.startsWith("https") ? HTTPS_PORT : HTTP_PORT); - - return `${parsedRequestUrl.protocol}//${parsedRequestUrl.hostname}:${port}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; - } - return requestUrl; - } - - private loadEnvironmentProxyValue(): string | undefined { - const HTTP_PROXY = "HTTP_PROXY"; - const HTTPS_PROXY = "HTTPS_PROXY"; - - if (!process) { - this.logger?.verbose( - "No process object found, unable to read environment variables for proxy.", - ); - return undefined; - } - - if (process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]) { - this.logger?.verbose("Loading proxy value from HTTP_PROXY environment variable."); - - return process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]; - } else if (process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]) { - this.logger?.verbose("Loading proxy value from HTTPS_PROXY environment variable."); - - return process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]; - } - - this.logger?.verbose( - "No proxy value found in either HTTPS_PROXY or HTTP_PROXY environment variables.", - ); - - return undefined; - } - - private createProxyAgent( - requestUrl: string, - proxy: string, - proxyStrictSSL?: boolean, - ): ProxyAgent { - const agentOptions = this.getProxyAgentOptions(new URL(requestUrl), proxy, proxyStrictSSL); - if (!agentOptions || !agentOptions.host || !agentOptions.port) { - this.logger?.error("Unable to read proxy agent options to create proxy agent."); - throw new Error( - this.dependencies.messages?.unableToGetProxyAgentOptions ?? - UnableToGetProxyAgentOptionsMessage, - ); - } - - let tunnelOptions: tunnel.HttpsOverHttpsOptions = {}; - if (typeof agentOptions.auth === "string" && agentOptions.auth) { - tunnelOptions = { - proxy: { - proxyAuth: agentOptions.auth, - host: agentOptions.host, - port: Number(agentOptions.port), - }, - }; - } else { - tunnelOptions = { - proxy: { - host: agentOptions.host, - port: Number(agentOptions.port), - }, - }; - } - - const isHttpsRequest = requestUrl.startsWith("https"); - const isHttpsProxy = proxy.startsWith("https"); - return { - agent: this.createTunnelingAgent(isHttpsRequest, isHttpsProxy, tunnelOptions), - }; - } - - private createTunnelingAgent( - isHttpsRequest: boolean, - isHttpsProxy: boolean, - tunnelOptions: tunnel.HttpsOverHttpsOptions, - ): http.Agent | https.Agent { - if (isHttpsRequest && isHttpsProxy) { - this.logger?.verbose("Creating https request over https proxy tunneling agent"); - return tunnel.httpsOverHttps(tunnelOptions); - } else if (isHttpsRequest && !isHttpsProxy) { - this.logger?.verbose("Creating https request over http proxy tunneling agent"); - return tunnel.httpsOverHttp(tunnelOptions); - } else if (!isHttpsRequest && isHttpsProxy) { - this.logger?.verbose("Creating http request over https proxy tunneling agent"); - return tunnel.httpOverHttps(tunnelOptions); - } else { - this.logger?.verbose("Creating http request over http proxy tunneling agent"); - return tunnel.httpOverHttp(tunnelOptions); - } - } - - /* - * Returns the proxy agent using the proxy url in the parameters or the system proxy. Returns null if no proxy found - */ - private getProxyAgentOptions( - requestURL: URL, - proxy?: string, - strictSSL?: boolean, - ): ProxyAgentOptions | undefined { - const proxyURL = proxy || this.getSystemProxyURL(requestURL); - - if (!proxyURL) { - return undefined; - } - - const proxyEndpoint = new URL(proxyURL); - if (!/^https?:$/.test(proxyEndpoint.protocol!)) { - return undefined; - } - - const auth = - proxyEndpoint.username || proxyEndpoint.password - ? `${proxyEndpoint.username}:${proxyEndpoint.password}` - : undefined; - - return { - host: proxyEndpoint.hostname, - port: proxyEndpoint.port - ? Number(proxyEndpoint.port) - : proxyEndpoint.protocol === "https:" - ? HTTPS_PORT - : HTTP_PORT, - auth, - // Default to rejecting unauthorized certs unless the user explicitly disables strict SSL. - rejectUnauthorized: strictSSL !== false, - }; - } - - private getSystemProxyURL(requestURL: URL): string | undefined { - if (requestURL.protocol === "http:") { - return process.env.HTTP_PROXY || process.env.http_proxy || undefined; - } else if (requestURL.protocol === "https:") { - return ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - undefined - ); - } - - return undefined; - } - - private getErrorMessage(error: unknown): string { - if (this.dependencies.getErrorMessage) { - return this.dependencies.getErrorMessage(error); - } - - if (error instanceof Error) { - return typeof error.message === "string" ? error.message : ""; - } - if (typeof error === "string") { - return error; - } - return `${JSON.stringify(error, undefined, "\t")}`; - } -} - -interface ProxyAgent { - agent: http.Agent | https.Agent; -} - -interface ProxyAgentOptions { - auth: string | undefined; - secureProxy?: boolean; - host?: string | null; - path?: string | null; - port?: string | number | null; - rejectUnauthorized: boolean; -} - -export class HttpDownloadError extends Error { - constructor( - public phase: "request" | "response", - public innerError: NodeJS.ErrnoException, - ) { - super(innerError.message); - } -} - -export interface IDownloadFileOptions { - onHeaders?: (headers: RawAxiosResponseHeaders) => void; - onData?: (data: Buffer) => void; -} - -export interface IDownloadFileResult { - status: number; - headers: RawAxiosResponseHeaders; -} diff --git a/extensions/sql-database-projects/src/tools/buildHelper.ts b/extensions/sql-database-projects/src/tools/buildHelper.ts index 19cd9e0718..3123624057 100644 --- a/extensions/sql-database-projects/src/tools/buildHelper.ts +++ b/extensions/sql-database-projects/src/tools/buildHelper.ts @@ -10,7 +10,7 @@ import * as utils from "../common/utils"; import * as sqldbproj from "../sqldbproj"; import extractZip = require("extract-zip"); import * as constants from "../common/constants"; -import { HttpClient } from "../http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import { getMicrosoftBuildSqlVersion } from "./netcoreTool"; import { ProjectType } from "../common/enums"; @@ -195,10 +195,40 @@ export class BuildHelper { outputChannel: vscode.OutputChannel, ): Promise { try { - const httpClient = new HttpClient(); + const httpClient = new VscodeHttpClient({ + messages: constants.Proxy, + }); outputChannel.appendLine(constants.downloadingFromTo(downloadUrl, nugetPath)); - await httpClient.download(downloadUrl, nugetPath, outputChannel); + let totalBytes: number | undefined; + let printThreshold = 0.1; + const result = await httpClient.downloadFile(downloadUrl, nugetPath, { + onProgress: (progress) => { + if (progress.downloadedBytes === 0) { + totalBytes = progress.totalBytes; + if (totalBytes !== undefined) { + outputChannel.appendLine( + `${constants.downloading} ${downloadUrl} (0 / ${(totalBytes / (1024 * 1024)).toFixed(2)} MB)`, + ); + } + return; + } + + if ( + totalBytes !== undefined && + progress.downloadedBytes / totalBytes >= printThreshold + ) { + outputChannel.appendLine( + `${constants.downloadProgress} (${(progress.downloadedBytes / (1024 * 1024)).toFixed(2)} / ${(totalBytes / (1024 * 1024)).toFixed(2)} MB)`, + ); + printThreshold += 0.1; + } + }, + }); + if (result.status !== 200) { + throw new Error(`HTTP ${result.status}`); + } } catch (e) { + outputChannel.appendLine(`${constants.downloadError}: ${utils.getErrorMessage(e)}`); throw new NugetDownloadError( constants.errorDownloading(downloadUrl, utils.getErrorMessage(e)), ); diff --git a/extensions/sql-database-projects/test/buildHelper.test.ts b/extensions/sql-database-projects/test/buildHelper.test.ts index 2853db839a..e18fba4413 100644 --- a/extensions/sql-database-projects/test/buildHelper.test.ts +++ b/extensions/sql-database-projects/test/buildHelper.test.ts @@ -10,7 +10,7 @@ import * as sinon from "sinon"; import * as vscode from "vscode"; import * as path from "path"; import { BuildHelper, NugetExtractionError } from "../src/tools/buildHelper"; -import { HttpClient } from "../src/http/httpClient"; +import { VscodeHttpClient } from "extension-toolkit/vscode"; import { TestContext, createContext } from "./testContext"; import { ProjectType } from "vscode-mssql"; import * as sqldbproj from "../src/sqldbproj"; @@ -127,7 +127,7 @@ suite("BuildHelper: Build Helper tests", function (): void { // Treat all files as present and fail the test if a network download is attempted. sandbox.stub(utils, "exists").resolves(true); sandbox - .stub(HttpClient.prototype, "download") + .stub(VscodeHttpClient.prototype, "downloadFile") .throws(new Error("download should not be called")); const testContext: TestContext = createContext(); @@ -173,7 +173,7 @@ suite("BuildHelper: Build Helper tests", function (): void { sandbox.stub(utils, "exists").resolves(false); // Make the actual HTTP download fail (simulates offline / proxy failure). - sandbox.stub(HttpClient.prototype, "download").rejects(new Error("ECONNREFUSED")); + sandbox.stub(VscodeHttpClient.prototype, "downloadFile").rejects(new Error("ECONNREFUSED")); // Capture the error message shown to the user. let shownMessage: string | undefined; @@ -267,7 +267,7 @@ suite("BuildHelper: Build Helper tests", function (): void { test("Returns true without downloading when all expected files already exist", async function (): Promise { // All files already present → download should never be called. sandbox.stub(utils, "exists").resolves(true); - const downloadSpy = sandbox.stub(HttpClient.prototype, "download"); + const downloadSpy = sandbox.stub(VscodeHttpClient.prototype, "downloadFile"); const outputChannel = { appendLine: () => {}, diff --git a/extensions/sql-database-projects/test/httpClient.test.ts b/extensions/sql-database-projects/test/httpClient.test.ts deleted file mode 100644 index 482b1fff29..0000000000 --- a/extensions/sql-database-projects/test/httpClient.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// NOTE: This file should always be kept in sync with the equivalent in the MSSQL extension: -// extensions/mssql/test/unit/httpClient.test.ts - -import * as chai from "chai"; -import { expect } from "chai"; -import sinonChai from "sinon-chai"; -import * as sinon from "sinon"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import { PassThrough } from "stream"; -import axios, { AxiosResponse } from "axios"; -import { HttpClient, HttpDownloadError } from "../src/http/httpClient"; -import { Logger } from "../src/common/logger"; - -chai.use(sinonChai); - -suite("HttpClient tests", () => { - let sandbox: sinon.SinonSandbox; - let httpClient: HttpClient; - let logger: sinon.SinonStubbedInstance; - - setup(() => { - sandbox = sinon.createSandbox(); - - logger = sandbox.createStubInstance(Logger); - httpClient = new HttpClient(logger); - }); - - teardown(() => { - sandbox.restore(); - }); - - suite("makeGetRequest tests", () => { - test("should make a successful GET request", async () => { - const requestUrl = "https://api.example.com/data"; - const token = "test-token"; - const responseData = { value: [{ id: 1, name: "test" }] }; - - const mockResponse: AxiosResponse = { - data: responseData, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse["config"], - }; - - const axiosGetStub = sandbox.stub(axios, "get").resolves(mockResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ - headers: { Authorization: `Bearer ${token}` }, - validateStatus: () => true, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); - - const result = await httpClient.makeGetRequest(requestUrl, token); - - expect(result).to.deep.equal(mockResponse); - expect(axiosGetStub).to.have.been.calledOnce; - }); - - test("should log GET request response", async () => { - const requestUrl = "https://api.example.com/data"; - const token = "test-token"; - const responseData = { value: [{ id: 1 }] }; - - const mockResponse: AxiosResponse = { - data: responseData, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse["config"], - }; - - sandbox.stub(axios, "get").resolves(mockResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({}); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); - - await httpClient.makeGetRequest(requestUrl, token); - - expect(logger.piiSanitized).to.have.been.calledWith( - "GET request ", - sinon.match.array, - [], - requestUrl, - ); - }); - }); - - suite("makePostRequest tests", () => { - test("should make a successful POST request", async () => { - const requestUrl = "https://api.example.com/data"; - const token = "test-token"; - const payload = { name: "new item" }; - const responseData = { id: 2, name: "new item" }; - - const mockResponse: AxiosResponse = { - data: responseData, - status: 201, - statusText: "Created", - headers: {}, - config: {} as AxiosResponse["config"], - }; - - const axiosPostStub = sandbox.stub(axios, "post").resolves(mockResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ - headers: { Authorization: `Bearer ${token}` }, - validateStatus: () => true, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); - - const result = await httpClient.makePostRequest(requestUrl, token, payload); - - expect(result).to.deep.equal(mockResponse); - expect(axiosPostStub).to.have.been.calledWith(requestUrl, payload, sinon.match.any); - }); - - test("should log POST request response", async () => { - const requestUrl = "https://api.example.com/data"; - const token = "test-token"; - const payload = { name: "test" }; - const responseData = { id: 1 }; - - const mockResponse: AxiosResponse = { - data: responseData, - status: 201, - statusText: "Created", - headers: {}, - config: {} as AxiosResponse["config"], - }; - - sandbox.stub(axios, "post").resolves(mockResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({}); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); - - await httpClient.makePostRequest(requestUrl, token, payload); - - expect(logger.piiSanitized).to.have.been.calledWith( - "POST request ", - sinon.match.array, - [], - requestUrl, - ); - }); - }); - - suite("downloadFile tests", () => { - test("should download successfully and invoke callbacks", async () => { - const requestUrl = "https://download.example.com/file"; - const normalizedUrl = "https://download.example.com:443/file"; - const headers = { "content-length": "5" }; - - const responseStream = new PassThrough(); - const tmpFileStream = new PassThrough(); - - sandbox - .stub(httpClient, "setupRequest") - .returns({ requestUrl: normalizedUrl, config: {} }); - - sandbox - .stub(fs, "createWriteStream") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .returns(tmpFileStream as any); - - const mockResponse: AxiosResponse = { - data: responseStream, - status: 200, - statusText: "OK", - headers, - config: {} as AxiosResponse["config"], - }; - sandbox.stub(axios, "get").resolves(mockResponse); - - const onHeaders = sandbox.spy(); - const onData = sandbox.spy(); - - const downloadPromise = httpClient.downloadFile(requestUrl, 123, { - onHeaders, - onData, - }); - - responseStream.write(Buffer.from([1, 2, 3])); - responseStream.end(Buffer.from([4, 5])); - - const result = await downloadPromise; - - expect(result.status).to.equal(200); - expect(result.headers).to.equal(headers); - expect(onHeaders).to.have.been.calledOnceWithExactly(headers); - expect(onData).to.have.callCount(2); - expect((onData.firstCall.args[0] as Buffer).length).to.equal(3); - expect((onData.secondCall.args[0] as Buffer).length).to.equal(2); - expect(axios.get).to.have.been.calledWith( - normalizedUrl, - sinon.match({ responseType: "stream" }), - ); - }); - - test("should return error code and destroy stream upon HTTP error", async () => { - const requestUrl = "https://download.example.com/file"; - const normalizedUrl = "https://download.example.com:443/file"; - const headers = { "content-length": "0" }; - - const responseStream = new PassThrough(); - const destroySpy = sandbox.spy(responseStream, "destroy"); - - sandbox - .stub(httpClient, "setupRequest") - .returns({ requestUrl: normalizedUrl, config: {} }); - - const mockResponse: AxiosResponse = { - data: responseStream, - status: 404, - statusText: "Not Found", - headers, - config: {} as AxiosResponse["config"], - }; - sandbox.stub(axios, "get").resolves(mockResponse); - - const onHeaders = sandbox.spy(); - const result = await httpClient.downloadFile(requestUrl, 123, { onHeaders }); - - expect(result.status).to.equal(404); - expect(result.headers).to.equal(headers); - expect(onHeaders).to.have.been.calledOnceWithExactly(headers); - expect(destroySpy).to.have.been.calledOnce; - }); - - test("should wrap request errors in HttpDownloadError", async () => { - const requestUrl = "https://download.example.com/file"; - - sandbox.stub(httpClient, "setupRequest").returns({ requestUrl, config: {} }); - - const requestError = new Error("network error") as NodeJS.ErrnoException; - requestError.code = "ECONNRESET"; - sandbox.stub(axios, "get").rejects(requestError); - - try { - await httpClient.downloadFile(requestUrl, 123); - expect.fail("Expected downloadFile to throw"); - } catch (error) { - expect(error).to.be.instanceOf(HttpDownloadError); - expect((error as HttpDownloadError).phase).to.equal("request"); - expect((error as HttpDownloadError).innerError).to.equal(requestError); - } - }); - - test("should wrap response stream errors in HttpDownloadError", async () => { - const requestUrl = "https://download.example.com/file"; - const responseStream = new PassThrough(); - const tmpFileStream = new PassThrough(); - - sandbox.stub(httpClient, "setupRequest").returns({ requestUrl, config: {} }); - sandbox - .stub(fs, "createWriteStream") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .returns(tmpFileStream as any); - - const mockResponse: AxiosResponse = { - data: responseStream, - status: 200, - statusText: "OK", - headers: {}, - config: {} as AxiosResponse["config"], - }; - sandbox.stub(axios, "get").resolves(mockResponse); - - const responseError = new Error("stream failed") as NodeJS.ErrnoException; - responseError.code = "EPIPE"; - - const downloadPromise = httpClient.downloadFile(requestUrl, 123); - await new Promise((resolve) => setImmediate(resolve)); - responseStream.emit("error", responseError); - - try { - await downloadPromise; - expect.fail("Expected downloadFile to throw"); - } catch (error) { - expect(error).to.be.instanceOf(HttpDownloadError); - expect((error as HttpDownloadError).phase).to.equal("response"); - expect((error as HttpDownloadError).innerError).to.equal(responseError); - } - }); - }); - - suite("Proxy validation tests", () => { - const envProxy = "env-proxy"; - const configProxy = "config-proxy"; - - test("warns when proxy lacks protocol", () => { - const invalidProxyValue = "localhost:1234"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); - - // Use URL constructor path: new URL("localhost:1234").protocol returns "localhost:" - // which is truthy, so it won't hit the missingProtocol branch unless we stub parseUriScheme. - // Stub the private parseUriScheme dependency to return undefined (simulating no scheme). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (httpClient as any).dependencies = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(httpClient as any).dependencies, - parseUriScheme: (_proxy: string) => undefined, - }; - - const warningMessageStub = sandbox - .stub(vscode.window, "showWarningMessage") - .resolves(undefined); - - httpClient.warnOnInvalidProxySettings(); - - // messages is now wired up, so showWarningMessage IS called. - expect(warningMessageStub).to.have.been.calledOnce; - expect(warningMessageStub.firstCall.args[0]).to.include(invalidProxyValue); - expect(logger.warn).to.have.been.calledOnce; - }); - - test("warns when proxy parsing throws", () => { - const invalidProxyValue = "env-proxy.example"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); - - // Force warnOnInvalidProxySettings to take the catch path by making URL constructor throw - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (httpClient as any).dependencies = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...(httpClient as any).dependencies, - parseUriScheme: (_proxy: string) => { - throw new Error("invalid uri format"); - }, - }; - - const warningMessageStub = sandbox - .stub(vscode.window, "showWarningMessage") - .resolves(undefined); - - httpClient.warnOnInvalidProxySettings(); - - // messages is now wired up, so showWarningMessage IS called. - expect(warningMessageStub).to.have.been.calledOnce; - expect(warningMessageStub.firstCall.args[0]).to.include(invalidProxyValue); - expect(logger.warn).to.have.been.calledOnce; - }); - - test("Does not warn when proxy is valid", () => { - const validProxyValues = [ - "http://valid-proxy.test:8080", - "https://valid-proxy.example", - "socks5://valid-proxy.subdomain.domain.com:1080", - ]; - - const proxyConfigStub = sandbox.stub(); - const warningMessageSpy = sandbox.stub(vscode.window, "showWarningMessage"); - - for (const validProxyValue of validProxyValues) { - proxyConfigStub.reset(); - httpClient["loadProxyConfig"] = proxyConfigStub.returns(validProxyValue); - - httpClient.warnOnInvalidProxySettings(); - - expect(warningMessageSpy, `Should not warn for valid proxy: ${validProxyValue}`).to - .not.have.been.called; - } - }); - - test("Does not warn when proxy is undefined", () => { - httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); - - const warningMessageSpy = sandbox.stub(vscode.window, "showWarningMessage"); - - httpClient.warnOnInvalidProxySettings(); - - expect(warningMessageSpy).to.not.have.been.called; - }); - - test("loadProxyConfig prefers VS Code configuration over environment variables", () => { - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxy: configProxy } as unknown as vscode.WorkspaceConfiguration); - - sandbox.stub(process, "env").value({ - HTTP_PROXY: envProxy, - https_proxy: envProxy, - }); - - const proxy = httpClient["loadProxyConfig"](); - - expect(proxy).to.equal(configProxy); - }); - - test("loadProxyConfig falls back to environment variables when config missing", () => { - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxy: undefined } as unknown as vscode.WorkspaceConfiguration); - - sandbox.stub(process, "env").value({ - HTTP_PROXY: envProxy, - }); - - const proxy = httpClient["loadProxyConfig"](); - - expect(proxy).to.equal(envProxy); - }); - - test("setupConfigAndProxyForRequest", () => { - const fakeToken = "fake-token"; - const fakeProxyUrl = new URL("http://fake-proxy.test:8080"); - - const loadProxyConfigStub = sandbox.stub(); - httpClient["loadProxyConfig"] = loadProxyConfigStub.returns(fakeProxyUrl.toString()); - - const result = httpClient["setupConfigAndProxyForRequest"]( - "http://fakeUrl.ms/", - fakeToken, - ); - - expect(result.headers.Authorization).to.contain(fakeToken); - expect(result.proxy, "Automatic proxy detection should be disabled").to.be.false; - expect(result.httpAgent.proxyOptions).to.deep.equal({ - host: fakeProxyUrl.hostname, - port: parseInt(fakeProxyUrl.port), - }); - expect(result.httpsAgent).to.be.undefined; - }); - }); - - suite("setupConfigAndProxyForRequest tests", () => { - test("should setup config without proxy", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); - - const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(result.headers).to.deep.equal({ - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }); - expect(result.validateStatus!(200)).to.be.true; - expect(result.proxy).to.be.undefined; - expect(result.httpAgent).to.be.undefined; - expect(result.httpsAgent).to.be.undefined; - }); - - test("should setup config with HTTPS proxy for HTTPS request", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - const proxy = "https://proxy.example.com:8080"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxyStrictSSL: true } as unknown as vscode.WorkspaceConfiguration); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "createProxyAgent").returns({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: {} as any, - }); - - const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(result.proxy).to.be.false; - expect(result.httpsAgent).to.exist; - expect(result.httpAgent).to.be.undefined; - }); - - test("should setup config with HTTP proxy for HTTPS request", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - const proxy = "http://proxy.example.com:8080"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxyStrictSSL: false } as unknown as vscode.WorkspaceConfiguration); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "createProxyAgent").returns({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: {} as any, - }); - - const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(result.proxy).to.be.false; - // HTTPS request URL → httpsAgent, regardless of proxy scheme - expect(result.httpsAgent).to.exist; - expect(result.httpAgent).to.be.undefined; - }); - - test("should log when proxy is found", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - const proxy = "http://proxy.example.com:8080"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - sandbox - .stub(vscode.workspace, "getConfiguration") - .withArgs("http") - .returns({ proxyStrictSSL: false } as unknown as vscode.WorkspaceConfiguration); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "createProxyAgent").returns({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: {} as any, - }); - - httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(logger.verbose).to.have.been.calledWith( - "Proxy endpoint found in environment variables or workspace configuration.", - ); - }); - }); -}); diff --git a/packages/extension-toolkit/package-lock.json b/packages/extension-toolkit/package-lock.json index dfe6787635..05f4b40d80 100644 --- a/packages/extension-toolkit/package-lock.json +++ b/packages/extension-toolkit/package-lock.json @@ -8,8 +8,13 @@ "name": "extension-toolkit", "version": "0.0.1", "license": "SEE LICENSE IN ../../LICENSE.txt", + "dependencies": { + "axios": "^1.18.0", + "tunnel": "0.0.6" + }, "devDependencies": { "@types/node": "^22.13.14", + "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", "typescript": "^5.8.3" } @@ -24,6 +29,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/tunnel": { + "version": "0.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/tunnel/-/tunnel-0.0.1.tgz", + "integrity": "sha1-DXJ3R2i3PfJvJd+RhCc6QtpysZw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/vscode": { "version": "1.101.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.101.0.tgz", @@ -31,6 +46,346 @@ "dev": true, "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha1-Sf/1hXfP7j83F2/qtMIuAPhtf3c=", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/axios/-/axios-1.18.1.tgz", + "integrity": "sha1-1j+YY7zYk4gVyG+eKr04AYnZbf4=", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha1-w9RaizT9cwYxoRCoolIGgrMdWn8=", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha1-8x274MGDsAptJutjJcgQwP0YvU0=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha1-KEdKFZ07nRHvYgUKFO1g5N9tYbw=", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha1-KOhk4beG2+u2jbH0UvljUnhmWCc=", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha1-LNxC1AvvLltO6rfAGnPFTOerWrw=", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha1-xZ7yJKBP6LdU89sAY6Jeow0ABdY=", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha1-u6vNwChZ9JhzAchW4zh85exDv3A=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha1-OBqHG2KnNEUGYK497uRIE/cNlZo=", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha1-p0h1aK2tV3z6qn6IxJyrOrMIGro=", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha1-cvExSzSlsZLbASMk3yzFh8pH+Sw=", + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/packages/extension-toolkit/package.json b/packages/extension-toolkit/package.json index f4dc6c5d54..3ef23cf249 100644 --- a/packages/extension-toolkit/package.json +++ b/packages/extension-toolkit/package.json @@ -44,8 +44,13 @@ "watch": "tsc -watch -p tsconfig.json --preserveWatchOutput", "lint": "eslint --quiet --cache ./src" }, + "dependencies": { + "axios": "^1.18.0", + "tunnel": "0.0.6" + }, "devDependencies": { "@types/node": "^22.13.14", + "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", "typescript": "^5.8.3" } diff --git a/packages/extension-toolkit/src/base/common/errors.ts b/packages/extension-toolkit/src/base/common/errors.ts new file mode 100644 index 0000000000..a6de5beab1 --- /dev/null +++ b/packages/extension-toolkit/src/base/common/errors.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Gets a message from an unknown error value. + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error + ? typeof error.message === "string" + ? error.message + : "" + : typeof error === "string" + ? error + : `${JSON.stringify(error, undefined, "\t")}`; +} diff --git a/packages/extension-toolkit/src/base/common/index.ts b/packages/extension-toolkit/src/base/common/index.ts new file mode 100644 index 0000000000..f987e5f074 --- /dev/null +++ b/packages/extension-toolkit/src/base/common/index.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from "./errors"; diff --git a/extensions/mssql/src/http/httpClientCore.ts b/packages/extension-toolkit/src/base/http/httpClient.ts similarity index 59% rename from extensions/mssql/src/http/httpClientCore.ts rename to packages/extension-toolkit/src/base/http/httpClient.ts index 9d778f7b60..e8af660e8d 100644 --- a/extensions/mssql/src/http/httpClientCore.ts +++ b/packages/extension-toolkit/src/base/http/httpClient.ts @@ -3,53 +3,143 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as tunnel from "tunnel"; +import * as fs from "fs"; import * as http from "http"; import * as https from "https"; -import * as fs from "fs"; -import axios, { AxiosRequestConfig, AxiosResponse, RawAxiosResponseHeaders } from "axios"; import { Readable } from "stream"; -import { ILogger } from "../sharedInterfaces/logger"; +import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import * as tunnel from "tunnel"; +import { getErrorMessage } from "../common"; -const UnableToGetProxyAgentOptionsMessage = "Unable to read proxy agent options to get tenants."; +const UnableToGetProxyAgentOptionsMessage = "Unable to read proxy agent options."; const HTTPS_PORT = 443; const HTTP_PORT = 80; +/** Optional logger contract used by the HTTP client for diagnostics and errors. */ +export interface IHttpClientLogger { + /** Writes a diagnostic message. */ + debug(message: string, ...args: unknown[]): void; + + /** Writes a warning message. */ + warn(message: string, ...args: unknown[]): void; + + /** Writes an error message. */ + error(message: string, ...args: unknown[]): void; + + /** + * Writes a message with explicit PII sanitization metadata. + */ + piiSanitized( + message: unknown, + objectsToSanitize: { name: string; objOrArray: unknown | unknown[] }[], + stringsToShorten: { name: string; value: string }[], + ...values: unknown[] + ): void; +} + +/** Localized proxy warning messages used by the HTTP client. */ export interface IHttpClientMessages { + /** Builds a warning when a proxy is configured without a protocol. */ missingProtocolWarning(proxy: string): string; + + /** Builds a warning when a proxy URL cannot be parsed. */ unparseableWarning(proxy: string, errorMessage: string): string; + + /** Message used when a proxy agent cannot be constructed. */ unableToGetProxyAgentOptions: string; } +/** Runtime integration points for proxy settings and warning presentation. */ export interface IHttpClientDependencies { + /** Returns the configured proxy endpoint, if available. */ getProxyConfig?: () => string | undefined; + + /** Returns whether proxy certificates should be validated. */ getProxyStrictSSL?: () => boolean | undefined; + + /** Parses a URI and returns its scheme. */ parseUriScheme?: (value: string) => string | undefined; + + /** Displays a warning message to the user. */ showWarningMessage?: (message: string) => void; - getErrorMessage?: (error: unknown) => string; + + /** Localized proxy warning messages. */ messages?: IHttpClientMessages; } +/** Progress payload for download callbacks. */ +export interface IDownloadProgress { + /** Number of bytes downloaded so far. */ + downloadedBytes: number; + + /** Total bytes, when known from response headers. */ + totalBytes?: number; + + /** Percentage in the range `[0, 100]`, when total bytes are known. */ + percentage?: number; +} + +/** Optional settings for file download operations. */ +export interface IDownloadFileOptions { + /** Receives progress updates while the response stream is being written. */ + onProgress?: (progress: IDownloadProgress) => void; +} + +/** Result returned by a completed download operation. */ +export interface IDownloadFileResult { + /** HTTP response status code. */ + status: number; + + /** Response headers from the download request. */ + headers: IHttpHeaders; +} + +/** HTTP header map with normalized string values. */ +export type IHttpHeaders = Record; + +/** Standardized HTTP response shape used by this toolkit. */ +export interface IHttpResponse { + /** Response payload. */ + data: TResponse; + + /** HTTP response status code. */ + status: number; + + /** HTTP status text, if provided by the transport layer. */ + statusText: string; + + /** Response headers. */ + headers: IHttpHeaders; +} + /** - * Core HTTP client class that is independent of VS Code APIs and can be used in any context, like the build/pipeline infrastructure. - * The HttpClient class extends this core class and provides VS Code specific implementations of the dependencies. + * Shared HTTP client with proxy support, optional diagnostics, and stream downloads. */ -export class HttpClientCore { +export class HttpClient { + /** + * Creates an HTTP client. + * + * @param logger Optional logger for diagnostics and warnings. + * @param dependencies Optional host-specific proxy and UI integrations. + */ constructor( - protected readonly logger?: ILogger, + protected readonly logger?: IHttpClientLogger, private readonly dependencies: IHttpClientDependencies = {}, ) {} /** - * Makes a GET request to the specified URL with the provided token. + * Sends an HTTP GET request. + * + * @param requestUrl Target URL. + * @param token Bearer token sent in the `Authorization` header. */ public async makeGetRequest( requestUrl: string, token: string, - ): Promise> { + ): Promise> { const request = this.setupRequest(requestUrl, token); - const response: AxiosResponse = await axios.get( + const response: AxiosResponse = await this.get( request.requestUrl, request.config, ); @@ -66,20 +156,24 @@ export class HttpClientCore { [], request.requestUrl, ); - return response; + return response as unknown as IHttpResponse; } /** - * Makes a POST request to the specified URL with the provided token and payload. + * Sends an HTTP POST request. + * + * @param requestUrl Target URL. + * @param token Bearer token sent in the `Authorization` header. + * @param payload JSON payload to post. */ public async makePostRequest( requestUrl: string, token: string, payload: TPayload, - ): Promise> { + ): Promise> { const request = this.setupRequest(requestUrl, token); - const response: AxiosResponse = await axios.post( + const response: AxiosResponse = await this.post( request.requestUrl, payload, request.config, @@ -90,17 +184,81 @@ export class HttpClientCore { [], request.requestUrl, ); - return response; + return response as unknown as IHttpResponse; } /** - * Downloads a file from the specified URL to the destination file descriptor, with optional callbacks for headers and data received. - * @param requestUrl request URL to download the file from - * @param destinationFd file descriptor of the destination file to write the downloaded content to - * @param options optional callbacks for headers and data received - * @returns result of the download operation, including the HTTP status and response headers + * Downloads a URL to a path or an open file descriptor. + * The caller retains ownership of a supplied file descriptor. + * + * @param requestUrl Target URL. + * @param destination Output path or open file descriptor. + * @param options Optional download settings including progress callback. */ public async downloadFile( + requestUrl: string, + destination: string | number, + options?: IDownloadFileOptions, + ): Promise { + const destinationFd = + typeof destination === "string" ? fs.openSync(destination, "w") : destination; + + try { + return await this.downloadToFileDescriptor(requestUrl, destinationFd, options); + } finally { + if (typeof destination === "string") { + fs.closeSync(destinationFd); + } + } + } + + /** + * Validates proxy settings and emits warnings for invalid values. + */ + public warnOnInvalidProxySettings(): void { + const proxy = this.loadProxyConfig(); + if (!proxy) { + return; + } + + let message = undefined; + let localizedMessage = undefined; + + try { + const scheme = this.dependencies.parseUriScheme + ? this.dependencies.parseUriScheme(proxy) + : new URL(proxy).protocol; + + if (!scheme) { + message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using this extension.`; + localizedMessage = this.dependencies.messages?.missingProtocolWarning(proxy); + } + } catch (error) { + const errorMessage = getErrorMessage(error); + message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using this extension. Error: ${errorMessage}`; + localizedMessage = this.dependencies.messages?.unparseableWarning(proxy, errorMessage); + } + + if (message) { + if (localizedMessage) { + this.dependencies.showWarningMessage?.(localizedMessage); + } + this.logger?.warn(message); + } + } + + private setupRequest( + requestUrl: string, + token?: string, + ): { requestUrl: string; config: AxiosRequestConfig } { + const config = this.setupConfigAndProxyForRequest(requestUrl, token); + return { + requestUrl: this.constructRequestUrl(requestUrl, config), + config, + }; + } + + private async downloadToFileDescriptor( requestUrl: string, destinationFd: number, options?: IDownloadFileOptions, @@ -113,44 +271,61 @@ export class HttpClientCore { let response: AxiosResponse; try { - response = await axios.get(request.requestUrl, requestConfig); - } catch (error: unknown) { + response = await this.get(request.requestUrl, requestConfig); + } catch (error) { throw new HttpDownloadError("request", error as NodeJS.ErrnoException); } - options?.onHeaders?.(response.headers); + const totalBytes = this.getContentLength(response.headers["content-length"]); + let downloadedBytes = 0; + options?.onProgress?.({ + downloadedBytes, + totalBytes, + percentage: totalBytes === undefined ? undefined : 0, + }); + if (response.status !== 200) { response.data.destroy(); return { status: response.status, - headers: response.headers, + headers: response.headers as unknown as IHttpHeaders, }; } await new Promise((resolve, reject) => { - const tmpFile = fs.createWriteStream("", { fd: destinationFd }); + const destinationStream = fs.createWriteStream("", { + fd: destinationFd, + autoClose: false, + }); let isSettled = false; - const rejectDownload = (err: NodeJS.ErrnoException) => { + const rejectDownload = (error: NodeJS.ErrnoException) => { if (isSettled) { return; } isSettled = true; response.data.destroy(); - tmpFile.destroy(); - reject(new HttpDownloadError("response", err)); + destinationStream.destroy(); + reject(new HttpDownloadError("response", error)); }; response.data.on("data", (data: Buffer) => { - options?.onData?.(data); + downloadedBytes += data.length; + options?.onProgress?.({ + downloadedBytes, + totalBytes, + percentage: + totalBytes === undefined + ? undefined + : Math.min(100, (downloadedBytes / totalBytes) * 100), + }); }); response.data.on("error", rejectDownload); + destinationStream.on("error", rejectDownload); - tmpFile.on("error", rejectDownload); - - tmpFile.on("close", () => { + destinationStream.on("finish", () => { if (isSettled) { return; } @@ -159,75 +334,15 @@ export class HttpClientCore { resolve(); }); - response.data.pipe(tmpFile); + response.data.pipe(destinationStream); }); return { status: response.status, - headers: response.headers, - }; - } - - public warnOnInvalidProxySettings(): void { - const proxy = this.loadProxyConfig(); - if (!proxy) { - return; - } - - let message = undefined; - let localizedMessage = undefined; - - try { - const scheme = this.dependencies.parseUriScheme - ? this.dependencies.parseUriScheme(proxy) - : new URL(proxy).protocol; - - if (!scheme) { - message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using this extension.`; - localizedMessage = this.dependencies.messages?.missingProtocolWarning(proxy); - } - } catch (err) { - const errorMessage = this.getErrorMessage(err); - message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using this extension. Error: ${errorMessage}`; - localizedMessage = this.dependencies.messages?.unparseableWarning(proxy, errorMessage); - } - - if (message) { - if (localizedMessage) { - this.dependencies.showWarningMessage?.(localizedMessage); - } - this.logger?.warn(message); - } - } - - /** - * Sets up the request URL and Axios request configuration, including headers and proxy/agent settings, based on the provided URL and token. - * Public for testing purposes. - */ - public setupRequest( - requestUrl: string, - token?: string, - ): { requestUrl: string; config: AxiosRequestConfig } { - const config = this.setupConfigAndProxyForRequest(requestUrl, token); - return { - requestUrl: this.constructRequestUrl(requestUrl, config), - config, + headers: response.headers as unknown as IHttpHeaders, }; } - /** - * Builds an Axios request config with headers, auth token, and proxy/agent settings. - * - * - Adds JSON content type and bearer token headers. - * - Disables Axios status throwing (`validateStatus` always true). - * - Checks VS Code HTTP proxy settings or environment variables. - * - If a proxy is found, disables Axios' default proxy handling - * and attaches a custom HTTP/HTTPS agent. - * - * @param requestUrl - The target request URL. - * @param token - Bearer token for the Authorization header. - * @returns AxiosRequestConfig with headers and proxy/agent configuration. - */ private setupConfigAndProxyForRequest(requestUrl: string, token?: string): AxiosRequestConfig { const headers: { "Content-Type": string; Authorization?: string } = { "Content-Type": "application/json", @@ -239,7 +354,7 @@ export class HttpClientCore { const config: AxiosRequestConfig = { headers, - validateStatus: () => true, // Never throw + validateStatus: () => true, }; const proxy = this.loadProxyConfig(); @@ -248,9 +363,6 @@ export class HttpClientCore { this.logger?.debug( "Proxy endpoint found in environment variables or workspace configuration.", ); - - // Turning off automatic proxy detection to avoid issues with tunneling agent by setting proxy to false. - // https://github.com/axios/axios/blob/bad6d8b97b52c0c15311c92dd596fc0bff122651/lib/adapters/http.js#L85 config.proxy = false; const agent = this.createProxyAgent( @@ -258,7 +370,7 @@ export class HttpClientCore { proxy, this.dependencies.getProxyStrictSSL?.(), ); - if (agent.isHttps) { + if (requestUrl.startsWith("https")) { config.httpsAgent = agent.agent; } else { config.httpAgent = agent.agent; @@ -267,14 +379,23 @@ export class HttpClientCore { return config; } - /** - * Attempts to read proxy configuration in priority order: - * 1. VS Code settings (http.proxy config key) - * 2. environment variables (HTTP_PROXY, then HTTPS_PROXY) - * @returns found proxy information - */ + private get( + requestUrl: string, + config: AxiosRequestConfig, + ): Promise> { + return axios.get(requestUrl, config); + } + + private post( + requestUrl: string, + payload: TPayload, + config: AxiosRequestConfig, + ): Promise> { + return axios.post(requestUrl, payload, config); + } + private loadProxyConfig(): string | undefined { - let proxy: string | undefined = this.dependencies.getProxyConfig?.(); + let proxy = this.dependencies.getProxyConfig?.(); if (!proxy) { this.logger?.debug( @@ -286,23 +407,9 @@ export class HttpClientCore { return proxy; } - /** - * Constructs a request URL that explicitly includes the port number when no proxy is configured. - * - * If a proxy is configured in the request config, the original URL is returned unchanged. - * - * @param requestUrl - The original request URL. - * @param config - The Axios request configuration, which may contain proxy settings. - * @returns A URL string with the appropriate port included if no proxy is configured, - * otherwise the original request URL. - */ private constructRequestUrl(requestUrl: string, config: AxiosRequestConfig): string { if (!config.proxy) { - // Request URL will include HTTPS port 443 ('https://management.azure.com:443/tenants?api-version=2019-11-01'), so - // that Axios doesn't try to reach this URL with HTTP port 80 on HTTP proxies, which result in an error. See https://github.com/axios/axios/issues/925 - const parsedRequestUrl = new URL(requestUrl); - // Preserve explicitly-specified ports (e.g., https://host:8443/...), only inject default when no port was provided const port = parsedRequestUrl.port || (parsedRequestUrl.protocol?.startsWith("https") ? HTTPS_PORT : HTTP_PORT); @@ -325,18 +432,15 @@ export class HttpClientCore { if (process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]) { this.logger?.debug("Loading proxy value from HTTP_PROXY environment variable."); - return process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]; } else if (process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]) { this.logger?.debug("Loading proxy value from HTTPS_PROXY environment variable."); - return process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]; } this.logger?.debug( "No proxy value found in either HTTPS_PROXY or HTTP_PROXY environment variables.", ); - return undefined; } @@ -354,28 +458,17 @@ export class HttpClientCore { ); } - let tunnelOptions: tunnel.HttpsOverHttpsOptions = {}; - if (typeof agentOptions.auth === "string" && agentOptions.auth) { - tunnelOptions = { - proxy: { - proxyAuth: agentOptions.auth, - host: agentOptions.host, - port: Number(agentOptions.port), - }, - }; - } else { - tunnelOptions = { - proxy: { - host: agentOptions.host, - port: Number(agentOptions.port), - }, - }; - } + const tunnelOptions: tunnel.HttpsOverHttpsOptions = { + proxy: { + host: agentOptions.host, + port: Number(agentOptions.port), + ...(agentOptions.auth ? { proxyAuth: agentOptions.auth } : {}), + }, + }; const isHttpsRequest = requestUrl.startsWith("https"); const isHttpsProxy = proxy.startsWith("https"); return { - isHttps: isHttpsProxy, agent: this.createTunnelingAgent(isHttpsRequest, isHttpsProxy, tunnelOptions), }; } @@ -394,28 +487,25 @@ export class HttpClientCore { } else if (!isHttpsRequest && isHttpsProxy) { this.logger?.debug("Creating http request over https proxy tunneling agent"); return tunnel.httpOverHttps(tunnelOptions); - } else { - this.logger?.debug("Creating http request over http proxy tunneling agent"); - return tunnel.httpOverHttp(tunnelOptions); } + + this.logger?.debug("Creating http request over http proxy tunneling agent"); + return tunnel.httpOverHttp(tunnelOptions); } - /* - * Returns the proxy agent using the proxy url in the parameters or the system proxy. Returns null if no proxy found - */ private getProxyAgentOptions( - requestURL: URL, + requestUrl: URL, proxy?: string, strictSSL?: boolean, ): ProxyAgentOptions | undefined { - const proxyURL = proxy || this.getSystemProxyURL(requestURL); + const proxyUrl = proxy || this.getSystemProxyUrl(requestUrl); - if (!proxyURL) { + if (!proxyUrl) { return undefined; } - const proxyEndpoint = new URL(proxyURL); - if (!/^https?:$/.test(proxyEndpoint.protocol!)) { + const proxyEndpoint = new URL(proxyUrl); + if (!/^https?:$/.test(proxyEndpoint.protocol)) { return undefined; } @@ -432,15 +522,14 @@ export class HttpClientCore { ? HTTPS_PORT : HTTP_PORT, auth, - // Default to rejecting unauthorized certs unless the user explicitly disables strict SSL. rejectUnauthorized: strictSSL !== false, }; } - private getSystemProxyURL(requestURL: URL): string | undefined { - if (requestURL.protocol === "http:") { + private getSystemProxyUrl(requestUrl: URL): string | undefined { + if (requestUrl.protocol === "http:") { return process.env.HTTP_PROXY || process.env.http_proxy || undefined; - } else if (requestURL.protocol === "https:") { + } else if (requestUrl.protocol === "https:") { return ( process.env.HTTPS_PROXY || process.env.https_proxy || @@ -453,36 +542,35 @@ export class HttpClientCore { return undefined; } - private getErrorMessage(error: unknown): string { - if (this.dependencies.getErrorMessage) { - return this.dependencies.getErrorMessage(error); + private getContentLength(header: unknown): number | undefined { + if (Array.isArray(header)) { + return this.getContentLength(header[0]); } - if (error instanceof Error) { - return typeof error.message === "string" ? error.message : ""; - } - if (typeof error === "string") { - return error; - } - return `${JSON.stringify(error, undefined, "\t")}`; + const value = typeof header === "number" ? header : Number.parseInt(`${header}`, 10); + return Number.isFinite(value) && value > 0 ? value : undefined; } } interface ProxyAgent { - isHttps: boolean; agent: http.Agent | https.Agent; } interface ProxyAgentOptions { auth: string | undefined; - secureProxy?: boolean; host?: string | null; - path?: string | null; port?: string | number | null; rejectUnauthorized: boolean; } +/** Error raised by `downloadFile` when request or response streaming fails. */ export class HttpDownloadError extends Error { + /** + * Creates a download error with phase metadata. + * + * @param phase Whether the failure happened while requesting or streaming. + * @param innerError The underlying Node.js error. + */ constructor( public phase: "request" | "response", public innerError: NodeJS.ErrnoException, @@ -490,13 +578,3 @@ export class HttpDownloadError extends Error { super(innerError.message); } } - -export interface IDownloadFileOptions { - onHeaders?: (headers: RawAxiosResponseHeaders) => void; - onData?: (data: Buffer) => void; -} - -export interface IDownloadFileResult { - status: number; - headers: RawAxiosResponseHeaders; -} diff --git a/packages/extension-toolkit/src/base/http/index.ts b/packages/extension-toolkit/src/base/http/index.ts new file mode 100644 index 0000000000..0ed0358766 --- /dev/null +++ b/packages/extension-toolkit/src/base/http/index.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from "./httpClient"; diff --git a/packages/extension-toolkit/src/base/index.ts b/packages/extension-toolkit/src/base/index.ts index 2c56b3ec25..f1cbcad903 100644 --- a/packages/extension-toolkit/src/base/index.ts +++ b/packages/extension-toolkit/src/base/index.ts @@ -3,5 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +export * from "./common"; export * from "./di"; +export * from "./http"; export * from "./lifecycle"; diff --git a/packages/extension-toolkit/src/vscode/http/index.ts b/packages/extension-toolkit/src/vscode/http/index.ts new file mode 100644 index 0000000000..bbbb414549 --- /dev/null +++ b/packages/extension-toolkit/src/vscode/http/index.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export * from "./vscodeHttpClient"; diff --git a/extensions/mssql/src/http/httpClient.ts b/packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts similarity index 50% rename from extensions/mssql/src/http/httpClient.ts rename to packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts index ee38242367..440ae403c8 100644 --- a/extensions/mssql/src/http/httpClient.ts +++ b/packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts @@ -4,19 +4,33 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from "vscode"; -import * as LocalizedConstants from "../constants/locConstants"; -import { ILogger } from "../sharedInterfaces/logger"; -import { getErrorMessage } from "../utils/utils"; import { - HttpClientCore, + HttpClient, IHttpClientDependencies, - HttpDownloadError, - IDownloadFileOptions, - IDownloadFileResult, -} from "./httpClientCore"; + IHttpClientLogger, + IHttpClientMessages, +} from "../../base"; -export class HttpClient extends HttpClientCore { - constructor(logger?: ILogger) { +/** Options for creating a VS Code-aware HTTP client. */ +export interface IVscodeHttpClientOptions { + /** Localized messages used when reporting invalid VS Code proxy settings. */ + messages: IHttpClientMessages; + + /** Optional logger for HTTP diagnostics and proxy configuration warnings. */ + logger?: IHttpClientLogger; +} + +/** + * An HTTP client configured from VS Code's `http.proxy` and `http.proxyStrictSSL` settings. + * Invalid proxy settings are reported through VS Code notifications and the optional logger. + */ +export class VscodeHttpClient extends HttpClient { + /** + * Creates a VS Code-aware HTTP client. + * + * @param options Localized proxy messages and optional diagnostic logger. + */ + constructor(options: IVscodeHttpClientOptions) { const dependencies: IHttpClientDependencies = { getProxyConfig: () => vscode.workspace.getConfiguration("http")["proxy"] as string | undefined, @@ -26,15 +40,8 @@ export class HttpClient extends HttpClientCore { showWarningMessage: (message: string) => { void vscode.window.showWarningMessage(message); }, - getErrorMessage, - messages: { - missingProtocolWarning: LocalizedConstants.Proxy.missingProtocolWarning, - unparseableWarning: LocalizedConstants.Proxy.unparseableWarning, - unableToGetProxyAgentOptions: LocalizedConstants.Proxy.unableToGetProxyAgentOptions, - }, + messages: options.messages, }; - super(logger, dependencies); + super(options.logger, dependencies); } } - -export { HttpDownloadError, IDownloadFileOptions, IDownloadFileResult }; diff --git a/packages/extension-toolkit/src/vscode/index.ts b/packages/extension-toolkit/src/vscode/index.ts index cb79fbd663..097a8013fa 100644 --- a/packages/extension-toolkit/src/vscode/index.ts +++ b/packages/extension-toolkit/src/vscode/index.ts @@ -5,3 +5,4 @@ export * from "../base"; export * from "./context/extensionContextService"; +export * from "./http"; From 55987ba97994e22b863cf24245cdf1c3b8639d5d Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Sat, 25 Jul 2026 00:10:18 -0700 Subject: [PATCH 2/2] Harden the shared HTTP client --- extensions/mssql/l10n/bundle.l10n.json | 14 +- .../mssql/src/azure/msal/msalAzureAuth.ts | 12 +- extensions/mssql/src/azure/utils.ts | 10 +- .../mssql/src/constants/locConstants.ts | 26 +- .../mssql/src/controllers/mainController.ts | 38 +- extensions/mssql/src/fabric/fabricHelper.ts | 70 +- .../src/languageservice/downloadHelper.ts | 58 +- .../serviceDownloadProvider.ts | 13 +- extensions/mssql/test/unit/download.test.ts | 156 +- extensions/mssql/test/unit/extension.test.ts | 17 +- .../mssql/test/unit/fabricHelper.test.ts | 58 +- extensions/mssql/test/unit/httpClient.test.ts | 499 ------ .../mssql/test/unit/languageService.test.ts | 72 +- .../mssql/test/unit/mainController.test.ts | 32 +- .../l10n/bundle.l10n.json | 6 +- .../src/common/constants.ts | 19 +- .../src/controllers/mainController.ts | 37 +- .../src/tools/buildHelper.ts | 7 +- .../test/buildHelper.test.ts | 8 +- localization/xliff/sql-database-projects.xlf | 14 +- localization/xliff/vscode-mssql.xlf | 21 +- packages/extension-toolkit/README.md | 41 + packages/extension-toolkit/package-lock.json | 1381 ++++++++++++++++- packages/extension-toolkit/package.json | 12 +- .../src/base/http/httpClient.ts | 1154 ++++++++------ .../src/base/http/httpErrors.ts | 62 + .../src/base/http/httpHeaders.ts | 167 ++ .../src/base/http/httpTypes.ts | 119 ++ .../extension-toolkit/src/base/http/index.ts | 4 + .../extension-toolkit/src/base/http/proxy.ts | 371 +++++ .../src/vscode/http/vscodeHttpClient.ts | 82 +- .../test/base/http/download.test.ts | 423 +++++ .../test/base/http/httpClient.test.ts | 507 ++++++ .../test/base/http/httpHeaders.test.ts | 96 ++ .../test/base/http/proxy.test.ts | 274 ++++ .../test/base/http/testServer.ts | 50 + packages/extension-toolkit/tsconfig.test.json | 11 + scripts/workspace-targets.mjs | 2 +- 38 files changed, 4785 insertions(+), 1158 deletions(-) delete mode 100644 extensions/mssql/test/unit/httpClient.test.ts create mode 100644 packages/extension-toolkit/src/base/http/httpErrors.ts create mode 100644 packages/extension-toolkit/src/base/http/httpHeaders.ts create mode 100644 packages/extension-toolkit/src/base/http/httpTypes.ts create mode 100644 packages/extension-toolkit/src/base/http/proxy.ts create mode 100644 packages/extension-toolkit/test/base/http/download.test.ts create mode 100644 packages/extension-toolkit/test/base/http/httpClient.test.ts create mode 100644 packages/extension-toolkit/test/base/http/httpHeaders.test.ts create mode 100644 packages/extension-toolkit/test/base/http/proxy.test.ts create mode 100644 packages/extension-toolkit/test/base/http/testServer.ts create mode 100644 packages/extension-toolkit/tsconfig.test.json diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 6bd607fad2..f4e5335458 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -803,6 +803,7 @@ "message": "Fabric long-running API error with error code '{0}': {1}", "comment": ["{0} is the error code", "{1} is the error message"] }, + "Fabric long-running operation response did not include a location header.": "Fabric long-running operation response did not include a location header.", "Fabric Account": "Fabric Account", "Fabric Account is required": "Fabric Account is required", "Workspace": "Workspace", @@ -1999,15 +2000,12 @@ "Are you sure you want to close the current session? All captured events will be lost. You can export events to CSV from the toolbar before closing.": "Are you sure you want to close the current session? All captured events will be lost. You can export events to CSV from the toolbar before closing.", "Select a database for profiling (Azure SQL requires a specific database)": "Select a database for profiling (Azure SQL requires a specific database)", "No databases found on the server. Please check your connection.": "No databases found on the server. Please check your connection.", - "Unable to read proxy agent options.": "Unable to read proxy agent options.", - "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension./{0} is the proxy URL": { - "message": "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension.", - "comment": ["{0} is the proxy URL"] - }, - "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}/{0} is the proxy URL{1} is the error message": { - "message": "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}", - "comment": ["{0} is the proxy URL", "{1} is the error message"] + "Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the MSSQL extension.": "Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the MSSQL extension.", + "Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the MSSQL extension./{0} is the proxy protocol": { + "message": "Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the MSSQL extension.", + "comment": ["{0} is the proxy protocol"] }, + "Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the MSSQL extension.": "Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the MSSQL extension.", "Backup Database - {0}/{0} is the database name": { "message": "Backup Database - {0}", "comment": ["{0} is the database name"] diff --git a/extensions/mssql/src/azure/msal/msalAzureAuth.ts b/extensions/mssql/src/azure/msal/msalAzureAuth.ts index cc79ed087f..0d63a6af3a 100644 --- a/extensions/mssql/src/azure/msal/msalAzureAuth.ts +++ b/extensions/mssql/src/azure/msal/msalAzureAuth.ts @@ -59,10 +59,7 @@ export abstract class MsalAzureAuth { this.scopes = [...this.providerSettings.scopes]; this.scopesString = this.scopes.join(" "); - this._httpHelper = new VscodeHttpClient({ - logger, - messages: LocalizedConstants.Proxy, - }); + this._httpHelper = new VscodeHttpClient({ logger }); } public async startLogin(): Promise { @@ -310,10 +307,9 @@ export abstract class MsalAzureAuth { try { this.logger.debug("Fetching tenants with uri {0}", tenantUri); let tenantList: string[] = []; - const tenantResponse = await this._httpHelper.makeGetRequest( - tenantUri, - token, - ); + const tenantResponse = await this._httpHelper.get(tenantUri, { + headers: { Authorization: `Bearer ${token}` }, + }); const data = tenantResponse.data; if (this.isErrorResponseBodyWithError(data)) { this.logger.error( diff --git a/extensions/mssql/src/azure/utils.ts b/extensions/mssql/src/azure/utils.ts index bc42c0774d..7ce55b92c5 100644 --- a/extensions/mssql/src/azure/utils.ts +++ b/extensions/mssql/src/azure/utils.ts @@ -12,7 +12,6 @@ import * as os from "os"; import * as vscode from "vscode"; import { VscodeHttpClient } from "extension-toolkit/vscode"; import { AzureAuthType, IToken, UserGroup } from "../models/contracts/azure"; -import * as LocalizedConstants from "../constants/locConstants"; import * as Constants from "./constants"; import { TokenCredentialWrapper } from "./credentialWrapper"; import { getLogger } from "../models/logger"; @@ -122,9 +121,7 @@ export function getAppDataPath(): string { export async function fetchUserGroups(userId: string): Promise { const graphBaseUri = vscode.Uri.parse("https://graph.microsoft.com/v1.0/"); const uri = vscode.Uri.joinPath(graphBaseUri, `users/${userId}/memberOf`); - const httpHelper = new VscodeHttpClient({ - messages: LocalizedConstants.Proxy, - }); + const httpHelper = new VscodeHttpClient(); const session = await vscode.authentication.getSession("microsoft", [], { createIfNone: true, @@ -134,14 +131,15 @@ export async function fetchUserGroups(userId: string): Promise { throw new Error("No access token found"); } + const headers = { Authorization: `Bearer ${token}` }; let groups: UserGroup[] = []; let nextUrl: string | undefined = uri.toString(); while (nextUrl) { try { - const response = await httpHelper.makeGetRequest<{ + const response = await httpHelper.get<{ value: UserGroup[]; "@odata.nextLink"?: string; - }>(nextUrl, token); + }>(nextUrl, { headers }); const result = response.data.value.map( (group) => ({ displayName: group.displayName, id: group.id }) as UserGroup, diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index ed4d96d672..c1a79be806 100644 --- a/extensions/mssql/src/constants/locConstants.ts +++ b/extensions/mssql/src/constants/locConstants.ts @@ -1406,6 +1406,10 @@ export class Fabric { }); }; + public static fabricLongRunningApiMissingLocation = l10n.t( + "Fabric long-running operation response did not include a location header.", + ); + public static fabricAccount = l10n.t("Fabric Account"); public static fabricAccountIsRequired = l10n.t("Fabric Account is required"); public static workspace = l10n.t("Workspace"); @@ -3691,23 +3695,21 @@ export class Profiler { } export class Proxy { - public static unableToGetProxyAgentOptions = l10n.t("Unable to read proxy agent options."); + public static missingProtocolWarning = l10n.t( + "Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the MSSQL extension.", + ); - public static missingProtocolWarning = (proxy: string) => + public static unsupportedProtocolWarning = (protocol: string) => l10n.t({ message: - "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension.", - args: [proxy], - comment: ["{0} is the proxy URL"], + "Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the MSSQL extension.", + args: [protocol], + comment: ["{0} is the proxy protocol"], }); - public static unparseableWarning = (proxy: string, errorMessage: string) => - l10n.t({ - message: - "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1}", - args: [proxy, errorMessage], - comment: ["{0} is the proxy URL", "{1} is the error message"], - }); + public static unparseableWarning = l10n.t( + "Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the MSSQL extension.", + ); } export class BackupDatabase { diff --git a/extensions/mssql/src/controllers/mainController.ts b/extensions/mssql/src/controllers/mainController.ts index 647c83f336..f372f26c3f 100644 --- a/extensions/mssql/src/controllers/mainController.ts +++ b/extensions/mssql/src/controllers/mainController.ts @@ -118,7 +118,11 @@ import { SearchDatabaseWebViewController } from "../searchDatabase/searchDatabas import { ChangelogWebviewController } from "./changelogWebviewController"; import { AzureDataStudioMigrationWebviewController } from "./azureDataStudioMigrationWebviewController"; import { ShortcutsConfigurationWebviewController } from "./shortcutsConfigurationWebviewController"; -import { VscodeHttpClient } from "extension-toolkit/vscode"; +import { + describeProxyConfigurationIssue, + getVscodeProxyConfigurationIssue, + ProxyConfigurationIssue, +} from "extension-toolkit/vscode"; import { ILogger } from "../sharedInterfaces/logger"; import { logger } from "../models/logger"; import { FileBrowserService } from "../services/fileBrowserService"; @@ -202,10 +206,20 @@ export default class MainController implements vscode.Disposable { this.configuration = vscode.workspace.getConfiguration(); UserSurvey.createInstance(this._context); - new VscodeHttpClient({ - logger: this._logger, - messages: LocalizedConstants.Proxy, - }).warnOnInvalidProxySettings(); + this.warnOnInvalidProxySettings(); + } + + /** + * Surfaces a localized warning when the `http.proxy` setting cannot be used. + */ + private warnOnInvalidProxySettings(): void { + const issue = getVscodeProxyConfigurationIssue(); + if (!issue) { + return; + } + + void vscode.window.showWarningMessage(getProxyIssueMessage(issue)); + this._logger.warn(describeProxyConfigurationIssue(issue)); } /** @@ -3645,3 +3659,17 @@ export default class MainController implements vscode.Disposable { } }; } + +/** + * Maps a proxy configuration issue onto the extension's localized warning text. + */ +function getProxyIssueMessage(issue: ProxyConfigurationIssue): string { + switch (issue.kind) { + case "missing-protocol": + return LocalizedConstants.Proxy.missingProtocolWarning; + case "unsupported-protocol": + return LocalizedConstants.Proxy.unsupportedProtocolWarning(issue.protocol); + default: + return LocalizedConstants.Proxy.unparseableWarning; + } +} diff --git a/extensions/mssql/src/fabric/fabricHelper.ts b/extensions/mssql/src/fabric/fabricHelper.ts index eb1ac35ecd..231a7647d3 100644 --- a/extensions/mssql/src/fabric/fabricHelper.ts +++ b/extensions/mssql/src/fabric/fabricHelper.ts @@ -18,7 +18,7 @@ import { } from "../sharedInterfaces/fabric"; import { IHttpResponse, VscodeHttpClient } from "extension-toolkit/vscode"; import { getErrorMessage } from "../utils/utils"; -import { Fabric as Loc, Proxy } from "../constants/locConstants"; +import { Fabric as Loc } from "../constants/locConstants"; import { getCloudProviderSettings } from "../azure/providerSettings"; import { ILogger } from "../sharedInterfaces/logger"; import { logger } from "../models/logger"; @@ -371,15 +371,14 @@ export class FabricHelper { ): Promise { const uri = vscode.Uri.joinPath(this.getFabricApiUriBase(), api); const fabricLogger = FabricHelper.getFabricLogger(); - const httpHelper = new VscodeHttpClient({ - logger: fabricLogger, - messages: Proxy, - }); + const httpHelper = new VscodeHttpClient({ logger: fabricLogger }); const session = await this.createScopedFabricSession(tenantId, reason); - let token = session?.accessToken; + const token = session?.accessToken; - const response = await httpHelper.makeGetRequest(uri.toString(), token); + const response = await httpHelper.get(uri.toString(), { + headers: this.createAuthorizationHeaders(token), + }); const result = response.data; if (isFabricError(result)) { @@ -400,28 +399,30 @@ export class FabricHelper { ): Promise { const uri = vscode.Uri.joinPath(this.getFabricApiUriBase(), api); const fabricLogger = FabricHelper.getFabricLogger(); - const httpHelper = new VscodeHttpClient({ - logger: fabricLogger, - messages: Proxy, - }); + const httpHelper = new VscodeHttpClient({ logger: fabricLogger }); const session = await this.createScopedFabricSession(tenantId, reason, scopes); const token = session?.accessToken; + const headers = this.createAuthorizationHeaders(token); - let response = await httpHelper.makePostRequest( - uri.toString(), - token, - payload, - ); + let response = await httpHelper.postJson(uri.toString(), payload, { + headers, + }); if (response.status === this.longRunningOperationCode) { fabricLogger.debug(`Handling long-running Fabric operation for API: ${api}`); + + const location = response.headers.get("location"); + if (!location) { + throw new Error(Loc.fabricLongRunningApiMissingLocation); + } + response = await this.handleLongRunningOperation( - response.headers["retry-after"] as string, - response.headers["location"], + response.headers.get("retry-after"), + location, httpHelper, fabricLogger, - token, + headers, ); } @@ -435,17 +436,30 @@ export class FabricHelper { return result; } + /** + * Builds the bearer authorization header for a Fabric request, if a token is available. + */ + private static createAuthorizationHeaders( + token: string | undefined, + ): Record | undefined { + return token ? { Authorization: `Bearer ${token}` } : undefined; + } + /** * Polls a long-running Fabric API operation until it completes, then fetches the final result. */ private static async handleLongRunningOperation( - retryAfter: string, + retryAfter: string | undefined, location: string, httpHelper: VscodeHttpClient, fabricLogger: ILogger, - token?: string, + headers: Record | undefined, ): Promise> { - const retryAfterInMs = parseInt(retryAfter, 10) || this.defaultRetryInMs; + const parsedRetryAfter = retryAfter === undefined ? Number.NaN : parseInt(retryAfter, 10); + const retryAfterInMs = + Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 + ? parsedRetryAfter + : this.defaultRetryInMs; let longRunningResponse: IHttpResponse | undefined; while ( @@ -457,7 +471,7 @@ export class FabricHelper { `Long-running operation in progress. Waiting ${retryAfterInMs} seconds before next poll...`, ); await new Promise((resolve) => setTimeout(resolve, retryAfterInMs * 1000)); - longRunningResponse = await httpHelper.makeGetRequest(location, token); + longRunningResponse = await httpHelper.get(location, { headers }); } if (longRunningResponse.data.status === IOperationStatus.Failed) { @@ -474,10 +488,12 @@ export class FabricHelper { `Long-running operation completed successfully. Fetching final result...`, ); - return await httpHelper.makeGetRequest( - longRunningResponse.headers["location"], - token, - ); + const resultLocation = longRunningResponse.headers.get("location"); + if (!resultLocation) { + throw new Error(Loc.fabricLongRunningApiMissingLocation); + } + + return await httpHelper.get(resultLocation, { headers }); } /** diff --git a/extensions/mssql/src/languageservice/downloadHelper.ts b/extensions/mssql/src/languageservice/downloadHelper.ts index 3a7917e829..772572042a 100644 --- a/extensions/mssql/src/languageservice/downloadHelper.ts +++ b/extensions/mssql/src/languageservice/downloadHelper.ts @@ -5,9 +5,10 @@ import { HttpClient, - HttpDownloadError, - IDownloadFileResult, + HttpClientError, + HttpClientErrorKind, IDownloadProgress, + IDownloadResult, } from "extension-toolkit/base"; import { ILogger } from "../sharedInterfaces/logger"; import { IPackage, IStatusView, PackageError } from "./interfaces"; @@ -34,7 +35,7 @@ export default class DownloadHelper { logger: ILogger, statusView: IStatusView, ): Promise { - if (!pkg.tmpFile || pkg.tmpFile.fd === 0) { + if (!pkg.tmpFile || pkg.tmpFile.fd === undefined || pkg.tmpFile.fd === null) { throw new PackageError("Temporary package file unavailable", pkg); } @@ -43,10 +44,10 @@ export default class DownloadHelper { downloadPercentage: 0, }; - const httpHelper = new HttpClient(logger); + const httpHelper = new HttpClient({ logger }); try { - const result: IDownloadFileResult = await httpHelper.downloadFile( + const result: IDownloadResult = await httpHelper.downloadToFileDescriptor( urlString, pkg.tmpFile.fd, { @@ -65,13 +66,11 @@ export default class DownloadHelper { throw error; } - if (error instanceof HttpDownloadError) { - const messagePrefix = - error.phase === "response" ? "Response error" : "Request error"; + if (error instanceof HttpClientError) { throw new PackageError( - `${messagePrefix}: ${error.innerError.code || "NONE"}`, + `${describeErrorKind(error.kind)}: ${error.code || "NONE"}`, pkg, - error.innerError, + error.cause, ); } @@ -94,8 +93,10 @@ export default class DownloadHelper { ); } - if (progress.percentage !== undefined && progress.totalBytes !== undefined) { - const newPercentage = Math.min(100, Math.ceil(progress.percentage)); + const percentage = calculatePercentage(progress.downloadedBytes, progress.totalBytes); + + if (percentage !== undefined && progress.totalBytes !== undefined) { + const newPercentage = Math.min(100, Math.ceil(percentage)); if (newPercentage !== progressState.downloadPercentage) { statusView.updateServiceDownloadingProgress(newPercentage); progressState.downloadPercentage = newPercentage; @@ -143,3 +144,36 @@ export interface IDownloadProgressState { downloadPercentage: number; dots: number; } + +/* + * Calculates the completed percentage of a download, or undefined when the total size is unknown + */ +function calculatePercentage( + downloadedBytes: number, + totalBytes: number | undefined, +): number | undefined { + if (totalBytes === undefined) { + return undefined; + } + + if (totalBytes === 0) { + return 100; + } + + return Math.min(100, (downloadedBytes / totalBytes) * 100); +} + +/* + * Maps a toolkit HTTP failure category onto the legacy package error message prefixes + */ +function describeErrorKind(kind: HttpClientErrorKind): string { + switch (kind) { + case "network": + case "timeout": + case "cancelled": + case "proxy-configuration": + return "Request error"; + default: + return "Response error"; + } +} diff --git a/extensions/mssql/src/languageservice/serviceDownloadProvider.ts b/extensions/mssql/src/languageservice/serviceDownloadProvider.ts index 9e4b0fcff7..f39101f936 100644 --- a/extensions/mssql/src/languageservice/serviceDownloadProvider.ts +++ b/extensions/mssql/src/languageservice/serviceDownloadProvider.ts @@ -18,6 +18,7 @@ import * as fs from "fs/promises"; import { ILogger } from "../sharedInterfaces/logger"; import DownloadHelper from "./downloadHelper"; import { getServiceExecutablePath, ServiceExecutable } from "./serviceExecutablePaths"; +import { getErrorMessage } from "../utils/utils"; /* * Service Download Provider class which handles downloading the SQL tools service. @@ -156,12 +157,22 @@ export default class ServiceDownloadProvider { try { await this._downloadHelper.downloadFile(pkg.url, pkg, this._logger, this._statusView); - this._logger.debug(`Downloaded to ${pkg.tmpFile.name}...`); + this._logger.debug(`Downloaded to ${tmpResult.name}...`); this._logger.info(" Done!"); await this.decompressAndInstallPackage(pkg); } catch (err) { this._logger.info(`[ERROR] ${err}`); throw err; + } finally { + try { + tmpResult.removeCallback(); + } catch (cleanupError) { + this._logger.warn( + `Failed to clean up temporary package file: ${getErrorMessage(cleanupError)}`, + ); + } finally { + pkg.tmpFile = undefined; + } } return true; } diff --git a/extensions/mssql/test/unit/download.test.ts b/extensions/mssql/test/unit/download.test.ts index a9c1fecc68..85570d3586 100644 --- a/extensions/mssql/test/unit/download.test.ts +++ b/extensions/mssql/test/unit/download.test.ts @@ -6,7 +6,8 @@ import * as sinon from "sinon"; import sinonChai from "sinon-chai"; import * as chai from "chai"; -import { IStatusView } from "../../src/languageservice/interfaces"; +import * as tmp from "tmp"; +import { IPackage, IStatusView, PackageError } from "../../src/languageservice/interfaces"; import ServiceDownloadProvider from "../../src/languageservice/serviceDownloadProvider"; import DownloadHelper from "../../src/languageservice/downloadHelper"; import DecompressProvider from "../../src/languageservice/decompressProvider"; @@ -299,3 +300,156 @@ suite("ServiceDownloadProvider Tests", () => { }); }); }); + +suite("ServiceDownloadProvider temporary file lifecycle", () => { + const installFolder = path.join(__dirname, "testServiceTempLifecycle"); + + async function captureError(action: () => Promise): Promise { + try { + await action(); + return undefined; + } catch (error) { + return error as Error; + } + } + + let sandbox: sinon.SinonSandbox; + let config: sinon.SinonStubbedInstance; + let statusView: sinon.SinonStubbedInstance; + let testDownloadHelper: sinon.SinonStubbedInstance; + let testDecompressProvider: sinon.SinonStubbedInstance; + let testLogger: sinon.SinonStubbedInstance; + let removeCallback: sinon.SinonStub; + let downloadProvider: ServiceDownloadProvider; + + setup(async () => { + sandbox = sinon.createSandbox(); + config = sandbox.createStubInstance(ConfigUtils); + statusView = sandbox.createStubInstance(ServerStatusView); + testDownloadHelper = sandbox.createStubInstance(DownloadHelper); + testDecompressProvider = sandbox.createStubInstance(DecompressProvider); + testLogger = createStubLogger(sandbox); + removeCallback = sandbox.stub(); + + await fs.rm(installFolder, { recursive: true, force: true }); + + config.getSqlToolsInstallDirectory.returns(installFolder); + config.getSqlToolsConfigValue + .withArgs("downloadFileNames") + .returns({ Windows_64: "fileName.zip" }); + config.getSqlToolsServiceDownloadUrl.returns("baseDownloadUrl/{#version#}/{#fileName#}"); + config.getSqlToolsPackageVersion.returns("1.0.0"); + + // Descriptor 0 is a valid descriptor, so it is used here to guard against falsy checks. + sandbox.stub(tmp, "file").callsFake((( + _options: tmp.Options, + callback: (err: unknown, path: string, fd: number, cleanupCallback: () => void) => void, + ) => { + callback(undefined, path.join(installFolder, "package-temp"), 0, removeCallback); + }) as typeof tmp.file); + + downloadProvider = new ServiceDownloadProvider( + config, + testLogger, + statusView, + testDownloadHelper, + testDecompressProvider, + ); + }); + + teardown(async () => { + sandbox.restore(); + await fs.rm(installFolder, { recursive: true, force: true }); + }); + + test("passes the temporary descriptor to the download helper even when it is zero", async () => { + testDownloadHelper.downloadFile.resolves(); + testDecompressProvider.decompress.resolves(); + + await downloadProvider.downloadAndInstallService(Runtime.Windows_64); + + const downloadedPackage = testDownloadHelper.downloadFile.firstCall.args[1] as IPackage; + expect(testDownloadHelper.downloadFile.firstCall.args[0]).to.equal( + "baseDownloadUrl/1.0.0/fileName.zip", + ); + expect(downloadedPackage.installPath).to.equal(installFolder); + }); + + test("removes the temporary package file exactly once after a successful install", async () => { + testDownloadHelper.downloadFile.resolves(); + testDecompressProvider.decompress.resolves(); + + await downloadProvider.downloadAndInstallService(Runtime.Windows_64); + + expect(removeCallback).to.have.been.calledOnce; + }); + + test("clears the package temporary file reference after a successful install", async () => { + testDownloadHelper.downloadFile.resolves(); + testDecompressProvider.decompress.resolves(); + + await downloadProvider.downloadAndInstallService(Runtime.Windows_64); + + const downloadedPackage = testDownloadHelper.downloadFile.firstCall.args[1] as IPackage; + expect(downloadedPackage.tmpFile).to.be.undefined; + }); + + test("removes the temporary package file when the download fails", async () => { + testDownloadHelper.downloadFile.rejects(new Error("download failed")); + + const thrownError = await captureError(() => + downloadProvider.downloadAndInstallService(Runtime.Windows_64), + ); + + expect(thrownError?.message).to.equal("download failed"); + expect(removeCallback).to.have.been.calledOnce; + expect(testDecompressProvider.decompress).to.not.have.been.called; + }); + + test("removes the temporary package file after an HTTP non-success response", async () => { + testDownloadHelper.downloadFile.rejects(new PackageError("404")); + + const thrownError = await captureError(() => + downloadProvider.downloadAndInstallService(Runtime.Windows_64), + ); + + expect(thrownError?.message).to.equal("404"); + expect(removeCallback).to.have.been.calledOnce; + expect(testDecompressProvider.decompress).to.not.have.been.called; + }); + + test("removes the temporary package file after a response-stream failure", async () => { + testDownloadHelper.downloadFile.rejects(new PackageError("Response error: ECONNRESET")); + + const thrownError = await captureError(() => + downloadProvider.downloadAndInstallService(Runtime.Windows_64), + ); + + expect(thrownError?.message).to.equal("Response error: ECONNRESET"); + expect(removeCallback).to.have.been.calledOnce; + expect(testDecompressProvider.decompress).to.not.have.been.called; + }); + + test("removes the temporary package file when decompression fails", async () => { + testDownloadHelper.downloadFile.resolves(); + testDecompressProvider.decompress.rejects(new Error("decompress failed")); + + const thrownError = await captureError(() => + downloadProvider.downloadAndInstallService(Runtime.Windows_64), + ); + + expect(thrownError?.message).to.equal("decompress failed"); + expect(removeCallback).to.have.been.calledOnce; + }); + + test("logs a warning and still succeeds when temporary file cleanup throws", async () => { + testDownloadHelper.downloadFile.resolves(); + testDecompressProvider.decompress.resolves(); + removeCallback.throws(new Error("cleanup failed")); + + const actual = await downloadProvider.downloadAndInstallService(Runtime.Windows_64); + + expect(actual).to.be.true; + expect(testLogger.warn).to.have.been.calledWithMatch("cleanup failed"); + }); +}); diff --git a/extensions/mssql/test/unit/extension.test.ts b/extensions/mssql/test/unit/extension.test.ts index edb293e70e..cb9414d608 100644 --- a/extensions/mssql/test/unit/extension.test.ts +++ b/extensions/mssql/test/unit/extension.test.ts @@ -23,7 +23,6 @@ import { RequestType } from "vscode-languageclient"; import { stubExtensionContext } from "./utils"; import { ChangelogWebviewController } from "../../src/controllers/changelogWebviewController"; import * as LocalizationCache from "../../src/controllers/localizationCache"; -import { VscodeHttpClient } from "extension-toolkit/vscode"; import { UserSurvey } from "../../src/nps/userSurvey"; import SqlToolsServerClient from "../../src/languageservice/serviceclient"; import * as UriOwnershipInitialization from "../../src/uriOwnership/uriOwnershipInitialization"; @@ -87,7 +86,6 @@ suite("Extension API Tests", () => { sandbox.stub(LocalizationCache, "initializeWebviewLocalizationCache").returns(); sandbox.stub(IconUtils, "initialize").returns(); sandbox.stub(UserSurvey, "createInstance").returns(); - sandbox.stub(VscodeHttpClient.prototype, "warnOnInvalidProxySettings").returns(); sandbox.stub(MainController.prototype, "activate").resolves(true); const sqlToolsClient: sinon.SinonStubbedInstance = @@ -95,10 +93,17 @@ suite("Extension API Tests", () => { sandbox.stub(sqlToolsClient, "sqlToolsServicePath").get(() => "test/sqltoolsservice"); sqlToolsClient.onNotification.returns(disposable); // handler stub necessary depending on test execution order - sandbox.stub(UriOwnershipInitialization, "createUriOwnershipCoordinator").returns({ - uriOwnershipApi: {}, - isActiveEditorOwnedByOtherExtensionWithWarning: () => false, - } as UriOwnershipCoordinator); + const uriOwnershipCoordinatorStub = Object.assign( + sandbox.createStubInstance(UriOwnershipCoordinator), + { + uriOwnershipApi: {}, + onCoordinatingOwnershipChanged: sandbox.stub(), + }, + ); + uriOwnershipCoordinatorStub.isActiveEditorOwnedByOtherExtensionWithWarning.returns(false); + sandbox + .stub(UriOwnershipInitialization, "createUriOwnershipCoordinator") + .returns(uriOwnershipCoordinatorStub); sandbox.stub(UriOwnershipInitialization, "initializeUriOwnershipCoordinator").returns(); vscodeMssql = await Extension.activate(context); diff --git a/extensions/mssql/test/unit/fabricHelper.test.ts b/extensions/mssql/test/unit/fabricHelper.test.ts index 95fbbc3abb..287ad634fe 100644 --- a/extensions/mssql/test/unit/fabricHelper.test.ts +++ b/extensions/mssql/test/unit/fabricHelper.test.ts @@ -8,7 +8,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as vscode from "vscode"; import sinonChai from "sinon-chai"; -import { IHttpResponse, VscodeHttpClient } from "extension-toolkit/vscode"; +import { createHttpHeaders, IHttpResponse, VscodeHttpClient } from "extension-toolkit/vscode"; import { FabricHelper } from "../../src/fabric/fabricHelper"; import { ICapacity, @@ -35,9 +35,12 @@ suite("FabricHelper", () => { data, status, statusText, - headers: {}, + ok: status >= 200 && status < 300, + headers: createHttpHeaders(), }); + const expectedGetOptions = { headers: { Authorization: "Bearer test-access-token" } }; + const mockTenantId = "test-tenant-id"; const mockCapacities: ICapacity[] = [ @@ -71,9 +74,7 @@ suite("FabricHelper", () => { sandbox = sinon.createSandbox(); mockHttpHelper = sandbox.createStubInstance(VscodeHttpClient); - sandbox - .stub(VscodeHttpClient.prototype, "makeGetRequest") - .callsFake(mockHttpHelper.makeGetRequest); + sandbox.stub(VscodeHttpClient.prototype, "get").callsFake(mockHttpHelper.get); sandbox.stub(vscode.authentication, "getSession").resolves(mockAuthSession); }); @@ -85,14 +86,14 @@ suite("FabricHelper", () => { suite("getFabricCapacities", () => { test("should return list of capacities", async () => { const mockResponse = { value: mockCapacities }; - mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); + mockHttpHelper.get.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricCapacities(mockTenantId); expect(result).to.deep.equal(mockCapacities); - expect(mockHttpHelper.makeGetRequest).to.have.been.calledOnceWith( + expect(mockHttpHelper.get).to.have.been.calledOnceWith( "https://api.fabric.microsoft.com/v1/capacities", - "test-access-token", + expectedGetOptions, ); }); @@ -101,9 +102,7 @@ suite("FabricHelper", () => { errorCode: "CapacityNotFound", message: "Capacity not found", }; - mockHttpHelper.makeGetRequest.resolves( - createHttpResponse(fabricError, 404, "Not Found"), - ); + mockHttpHelper.get.resolves(createHttpResponse(fabricError, 404, "Not Found")); try { await FabricHelper.getFabricCapacities(mockTenantId); @@ -149,14 +148,14 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockWorkspaces }; - mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); + mockHttpHelper.get.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricWorkspaces(capacityId); expect(result).to.deep.equal(mockWorkspaces); - expect(mockHttpHelper.makeGetRequest).to.have.been.calledOnceWith( + expect(mockHttpHelper.get).to.have.been.calledOnceWith( "https://api.fabric.microsoft.com/v1/workspaces", - "test-access-token", + expectedGetOptions, ); }); @@ -177,14 +176,14 @@ suite("FabricHelper", () => { }; const mockResponse = { value: mockWorkspace }; - mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); + mockHttpHelper.get.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricWorkspace("workspace-1", "mock-tenant-id"); expect(result).to.deep.equal({ value: mockWorkspace }); - expect(mockHttpHelper.makeGetRequest).to.have.been.calledOnceWith( + expect(mockHttpHelper.get).to.have.been.calledOnceWith( "https://api.fabric.microsoft.com/v1/workspaces/workspace-1", - "test-access-token", + expectedGetOptions, ); }); @@ -206,7 +205,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockDatabases }; - mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); + mockHttpHelper.get.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricDatabases( { displayName: "Test Workspace", id: "test-workspace-id" } as IWorkspace, @@ -225,9 +224,9 @@ suite("FabricHelper", () => { type: "SQLDatabase", }, ]); - expect(mockHttpHelper.makeGetRequest).to.have.been.calledOnceWith( + expect(mockHttpHelper.get).to.have.been.calledOnceWith( "https://api.fabric.microsoft.com/v1/workspaces/test-workspace-id/sqlDatabases", - "test-access-token", + expectedGetOptions, ); }); @@ -244,7 +243,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockDatabases }; - mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); + mockHttpHelper.get.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricSqlEndpoints( { displayName: "Test Workspace", id: "test-workspace-id" } as IWorkspace, @@ -263,9 +262,9 @@ suite("FabricHelper", () => { type: "SQLEndpoint", }, ]); - expect(mockHttpHelper.makeGetRequest).to.have.been.calledOnceWith( + expect(mockHttpHelper.get).to.have.been.calledOnceWith( "https://api.fabric.microsoft.com/v1/workspaces/test-workspace-id/sqlEndpoints", - "test-access-token", + expectedGetOptions, ); }); @@ -287,7 +286,7 @@ suite("FabricHelper", () => { ]; const mockResponse = { value: mockWarehouses }; - mockHttpHelper.makeGetRequest.resolves(createHttpResponse(mockResponse)); + mockHttpHelper.get.resolves(createHttpResponse(mockResponse)); const result = await FabricHelper.getFabricWarehouses( { displayName: "Test Workspace", id: "test-workspace-id" } as IWorkspace, @@ -306,9 +305,9 @@ suite("FabricHelper", () => { type: "Warehouse", }, ]); - expect(mockHttpHelper.makeGetRequest).to.have.been.calledOnceWith( + expect(mockHttpHelper.get).to.have.been.calledOnceWith( "https://api.fabric.microsoft.com/v1/workspaces/test-workspace-id/warehouses", - "test-access-token", + expectedGetOptions, ); }); @@ -319,12 +318,7 @@ suite("FabricHelper", () => { message: "Capacity not found", }; - mockHttpHelper.makeGetRequest.resolves({ - data: fabricError, - status: 404, - statusText: "Not Found", - headers: {}, - }); + mockHttpHelper.get.resolves(createHttpResponse(fabricError, 404, "Not Found")); try { await FabricHelper.getFabricWorkspaces(capacityId); diff --git a/extensions/mssql/test/unit/httpClient.test.ts b/extensions/mssql/test/unit/httpClient.test.ts deleted file mode 100644 index 87856aa477..0000000000 --- a/extensions/mssql/test/unit/httpClient.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as chai from "chai"; -import { expect } from "chai"; -import sinonChai from "sinon-chai"; -import * as sinon from "sinon"; -import * as fs from "fs"; -import { PassThrough, Writable } from "stream"; -import { HttpClient, HttpDownloadError } from "extension-toolkit/base"; -import * as LocalizedConstants from "../../src/constants/locConstants"; -import { ILogger } from "../../src/sharedInterfaces/logger"; -import { createStubLogger } from "./utils"; - -chai.use(sinonChai); - -suite("HttpClient tests", () => { - let sandbox: sinon.SinonSandbox; - let httpClient: HttpClient; - let logger: sinon.SinonStubbedInstance; - let getProxyConfig: sinon.SinonStub; - let parseUriScheme: sinon.SinonStub; - let showWarningMessage: sinon.SinonStub; - - setup(() => { - sandbox = sinon.createSandbox(); - - logger = createStubLogger(sandbox); - getProxyConfig = sandbox.stub().returns(undefined); - parseUriScheme = sandbox.stub().callsFake((value: string) => new URL(value).protocol); - showWarningMessage = sandbox.stub(); - httpClient = new HttpClient(logger, { - getProxyConfig, - parseUriScheme, - showWarningMessage, - messages: LocalizedConstants.Proxy, - }); - }); - - teardown(() => { - sandbox.restore(); - }); - - suite("makeGetRequest tests", () => { - test("should make a successful GET request", async () => { - const requestUrl = "https://api.example.com/data"; - const token = "test-token"; - const responseData = { value: [{ id: 1, name: "test" }] }; - - const mockResponse = { - data: responseData, - status: 200, - statusText: "OK", - headers: {}, - }; - - const getStub = sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "get") - .resolves(mockResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ - headers: { Authorization: `Bearer ${token}` }, - validateStatus: () => true, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); - - const result = await httpClient.makeGetRequest(requestUrl, token); - - expect(result).to.deep.equal(mockResponse); - expect(getStub).to.have.been.calledWith(requestUrl, sinon.match.any); - }); - }); - - suite("makePostRequest tests", () => { - test("should make a successful POST request", async () => { - const requestUrl = "https://api.example.com/data"; - const token = "test-token"; - const payload = { name: "new item" }; - const responseData = { id: 2, name: "new item" }; - - const mockResponse = { - data: responseData, - status: 201, - statusText: "Created", - headers: {}, - }; - - const postStub = sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "post") - .resolves(mockResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "setupConfigAndProxyForRequest").returns({ - headers: { Authorization: `Bearer ${token}` }, - validateStatus: () => true, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "constructRequestUrl").returns(requestUrl); - - const result = await httpClient.makePostRequest(requestUrl, token, payload); - - expect(result).to.deep.equal(mockResponse); - expect(postStub).to.have.been.calledWith(requestUrl, payload, sinon.match.any); - }); - }); - - suite("downloadFile tests", () => { - test("should download successfully and invoke callbacks", async () => { - const requestUrl = "https://download.example.com/file"; - const normalizedUrl = "https://download.example.com:443/file"; - const headers = { "content-length": "5" }; - - const responseStream = new PassThrough(); - const receivedChunkLengths: number[] = []; - let releaseWriteStream: (() => void) | undefined; - const tmpFileStream = new Writable({ - write(chunk, _encoding, callback) { - receivedChunkLengths.push((chunk as Buffer).length); - callback(); - }, - final(callback) { - releaseWriteStream = callback; - }, - }); - - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "setupRequest") - .returns({ requestUrl: normalizedUrl, config: {} }); - - sandbox - .stub(fs, "createWriteStream") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .returns(tmpFileStream as any); - - const mockResponse = { - data: responseStream, - status: 200, - statusText: "OK", - headers, - }; - const getStub = sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "get") - .resolves(mockResponse); - - const onProgress = sandbox.spy(); - let downloadCompleted = false; - - const downloadPromise = httpClient - .downloadFile(requestUrl, 123, { - onProgress, - }) - .then((result) => { - downloadCompleted = true; - return result; - }); - - responseStream.write(Buffer.from([1, 2, 3])); - responseStream.end(Buffer.from([4, 5])); - - await new Promise((resolve) => setImmediate(resolve)); - - expect(downloadCompleted).to.be.false; - expect(releaseWriteStream).to.not.be.undefined; - - releaseWriteStream?.(); - - const result = await downloadPromise; - - expect(result.status).to.equal(200); - expect(result.headers).to.equal(headers); - expect(receivedChunkLengths).to.deep.equal([3, 2]); - expect(onProgress.args.map((args) => args[0])).to.deep.equal([ - { downloadedBytes: 0, totalBytes: 5, percentage: 0 }, - { downloadedBytes: 3, totalBytes: 5, percentage: 60 }, - { downloadedBytes: 5, totalBytes: 5, percentage: 100 }, - ]); - expect(getStub).to.have.been.calledWith( - normalizedUrl, - sinon.match({ responseType: "stream" }), - ); - expect(fs.createWriteStream).to.have.been.calledWith( - "", - sinon.match({ fd: 123, autoClose: false }), - ); - }); - - test("should return error code and destroy stream upon HTTP error", async () => { - const requestUrl = "https://download.example.com/file"; - const normalizedUrl = "https://download.example.com:443/file"; - const headers = { "content-length": "0" }; - - const responseStream = new PassThrough(); - const destroySpy = sandbox.spy(responseStream, "destroy"); - - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "setupRequest") - .returns({ requestUrl: normalizedUrl, config: {} }); - - const mockResponse = { - data: responseStream, - status: 404, - statusText: "Not Found", - headers, - }; - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "get") - .resolves(mockResponse); - - const onProgress = sandbox.spy(); - const result = await httpClient.downloadFile(requestUrl, 123, { onProgress }); - - expect(result.status).to.equal(404); - expect(result.headers).to.equal(headers); - expect(onProgress).to.have.been.calledWithExactly({ - downloadedBytes: 0, - totalBytes: undefined, - percentage: undefined, - }); - expect(destroySpy).to.have.been.calledOnce; - }); - - test("should open and close path destinations", async () => { - const result = { status: 200, headers: {} }; - const openStub = sandbox.stub(fs, "openSync").returns(123); - const closeStub = sandbox.stub(fs, "closeSync"); - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "downloadToFileDescriptor") - .resolves(result); - - expect( - await httpClient.downloadFile("https://example.com/file", "target.zip"), - ).to.equal(result); - expect(openStub).to.have.been.calledWith("target.zip", "w"); - expect(closeStub).to.have.been.calledWith(123); - }); - - test("should wrap request errors in HttpDownloadError", async () => { - const requestUrl = "https://download.example.com/file"; - - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "setupRequest") - .returns({ requestUrl, config: {} }); - - const requestError = new Error("network error") as NodeJS.ErrnoException; - requestError.code = "ECONNRESET"; - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "get") - .rejects(requestError); - - try { - await httpClient.downloadFile(requestUrl, 123); - expect.fail("Expected downloadFile to throw"); - } catch (error) { - expect(error).to.be.instanceOf(HttpDownloadError); - expect((error as HttpDownloadError).phase).to.equal("request"); - expect((error as HttpDownloadError).innerError).to.equal(requestError); - } - }); - - test("should wrap response stream errors in HttpDownloadError", async () => { - const requestUrl = "https://download.example.com/file"; - const responseStream = new PassThrough(); - const tmpFileStream = new PassThrough(); - - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "setupRequest") - .returns({ requestUrl, config: {} }); - sandbox - .stub(fs, "createWriteStream") - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .returns(tmpFileStream as any); - - const mockResponse = { - data: responseStream, - status: 200, - statusText: "OK", - headers: {}, - }; - sandbox - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .stub(httpClient as any, "get") - .resolves(mockResponse); - - const responseError = new Error("stream failed") as NodeJS.ErrnoException; - responseError.code = "EPIPE"; - - const downloadPromise = httpClient.downloadFile(requestUrl, 123); - await new Promise((resolve) => setImmediate(resolve)); - responseStream.emit("error", responseError); - - try { - await downloadPromise; - expect.fail("Expected downloadFile to throw"); - } catch (error) { - expect(error).to.be.instanceOf(HttpDownloadError); - expect((error as HttpDownloadError).phase).to.equal("response"); - expect((error as HttpDownloadError).innerError).to.equal(responseError); - } - }); - }); - - suite("Proxy validation tests", () => { - const envProxy = "env-proxy"; - const configProxy = "config-proxy"; - - test("warns when proxy lacks protocol", () => { - const invalidProxyValue = "localhost:1234"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); - - parseUriScheme.withArgs(invalidProxyValue).returns(undefined); - - httpClient.warnOnInvalidProxySettings(); - - expect(showWarningMessage).to.have.been.calledWithExactly( - LocalizedConstants.Proxy.missingProtocolWarning(invalidProxyValue), - ); - }); - - test("warns when proxy parsing throws", () => { - const invalidProxyValue = "env-proxy.example"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(invalidProxyValue); - - const uriError = new Error("invalid uri format"); - parseUriScheme.withArgs(invalidProxyValue).throws(uriError); - - httpClient.warnOnInvalidProxySettings(); - - expect(showWarningMessage).to.have.been.calledWithExactly( - LocalizedConstants.Proxy.unparseableWarning(invalidProxyValue, uriError.message), - ); - }); - - test("Does not warn when proxy is valid", () => { - const validProxyValues = [ - "http://valid-proxy.test:8080", - "https://valid-proxy.example", - "socks5://valid-proxy.subdomain.domain.com:1080", - ]; - - const proxyConfigStub = sandbox.stub(); - for (const validProxyValue of validProxyValues) { - proxyConfigStub.reset(); - httpClient["loadProxyConfig"] = proxyConfigStub.returns(validProxyValue); - - httpClient.warnOnInvalidProxySettings(); - - expect(showWarningMessage, `Should not warn for valid proxy: ${validProxyValue}`).to - .not.have.been.called; - } - }); - - test("Does not warn when proxy is undefined", () => { - httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); - - httpClient.warnOnInvalidProxySettings(); - - expect(showWarningMessage).to.not.have.been.called; - }); - - test("loadProxyConfig prefers VS Code configuration over environment variables", () => { - getProxyConfig.returns(configProxy); - - sandbox.stub(process, "env").value({ - HTTP_PROXY: envProxy, - https_proxy: envProxy, - }); - - const proxy = httpClient["loadProxyConfig"](); - - expect(proxy).to.equal(configProxy); - }); - - test("loadProxyConfig falls back to environment variables when config missing", () => { - sandbox.stub(process, "env").value({ - HTTP_PROXY: envProxy, - }); - - const proxy = httpClient["loadProxyConfig"](); - - expect(proxy).to.equal(envProxy); - }); - - test("setupConfigAndProxyForRequest", () => { - const fakeToken = "fake-token"; - const fakeProxyUrl = new URL("http://fake-proxy.test:8080"); - - const loadProxyConfigStub = sandbox.stub(); - httpClient["loadProxyConfig"] = loadProxyConfigStub.returns(fakeProxyUrl.toString()); - - const result = httpClient["setupConfigAndProxyForRequest"]( - "http://fakeUrl.ms/", - fakeToken, - ); - - expect(result.headers.Authorization).to.contain(fakeToken); - expect(result.proxy, "Automatic proxy detection should be disabled").to.be.false; - expect(result.httpAgent.proxyOptions).to.deep.equal({ - host: fakeProxyUrl.hostname, - port: parseInt(fakeProxyUrl.port), - }); - expect(result.httpsAgent).to.be.undefined; - }); - }); - - suite("setupConfigAndProxyForRequest tests", () => { - test("should setup config without proxy", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(undefined); - - const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(result.headers).to.deep.equal({ - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }); - expect(result.validateStatus!(200)).to.be.true; - expect(result.proxy).to.be.undefined; - expect(result.httpAgent).to.be.undefined; - expect(result.httpsAgent).to.be.undefined; - }); - - test("should setup config with HTTPS proxy for HTTPS request", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - const proxy = "https://proxy.example.com:8080"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "createProxyAgent").returns({ - isHttps: true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: {} as any, - }); - - const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(result.proxy).to.be.false; - expect(result.httpsAgent).to.exist; - expect(result.httpAgent).to.be.undefined; - }); - - test("should setup config with HTTP proxy for HTTPS request", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - const proxy = "http://proxy.example.com:8080"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "createProxyAgent").returns({ - isHttps: false, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: {} as any, - }); - - const result = httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect(result.proxy).to.be.false; - expect(result.httpsAgent).to.exist; - expect(result.httpAgent).to.be.undefined; - }); - - test("should create proxy agent when proxy is found", () => { - const requestUrl = "https://api.example.com"; - const token = "test-token"; - const proxy = "http://proxy.example.com:8080"; - - httpClient["loadProxyConfig"] = sandbox.stub().returns(proxy); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sandbox.stub(httpClient as any, "createProxyAgent").returns({ - isHttps: false, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent: {} as any, - }); - - httpClient["setupConfigAndProxyForRequest"](requestUrl, token); - - expect((httpClient as any).createProxyAgent).to.have.been.called; - }); - }); -}); diff --git a/extensions/mssql/test/unit/languageService.test.ts b/extensions/mssql/test/unit/languageService.test.ts index 57861fb6d9..b2ba971690 100644 --- a/extensions/mssql/test/unit/languageService.test.ts +++ b/extensions/mssql/test/unit/languageService.test.ts @@ -4,13 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import * as sinon from "sinon"; +import * as chai from "chai"; +import sinonChai from "sinon-chai"; import { expect } from "chai"; -import { IDownloadProgress } from "extension-toolkit/base"; +import { createHttpHeaders, HttpClient, IDownloadProgress } from "extension-toolkit/base"; import DecompressProvider from "../../src/languageservice/decompressProvider"; import { IPackage, IStatusView } from "../../src/languageservice/interfaces"; import DownloadHelper, { IDownloadProgressState } from "../../src/languageservice/downloadHelper"; import { stubILogger } from "./utils"; +chai.use(sinonChai); + +function createStubStatusView(): IStatusView { + return { + installingService: () => undefined, + serviceInstalled: () => undefined, + serviceInstallationFailed: () => undefined, + updateServiceDownloadingProgress: (_downloadPercentage: number) => undefined, + }; +} + suite("Language Service Tests", () => { let sandbox: sinon.SinonSandbox; @@ -43,28 +56,69 @@ suite("Language Service Tests", () => { suite("DownloadHelper Tests", () => { let downloadHelper = new DownloadHelper(); + test("downloadFile accepts descriptor zero", async () => { + const downloadToFileDescriptor = sandbox + .stub(HttpClient.prototype, "downloadToFileDescriptor") + .resolves({ + status: 200, + statusText: "OK", + ok: true, + headers: createHttpHeaders(), + }); + const testPackage: IPackage = { + url: "test_url", + tmpFile: { name: "temp", fd: 0, removeCallback: () => undefined }, + isZipFile: true, + }; + + await downloadHelper.downloadFile( + testPackage.url, + testPackage, + stubILogger(sandbox), + createStubStatusView(), + ); + + expect(downloadToFileDescriptor).to.have.been.calledOnce; + expect(downloadToFileDescriptor.firstCall.args[1]).to.equal(0); + }); + + test("downloadFile rejects when the temporary descriptor is missing", async () => { + const testPackage: IPackage = { + url: "test_url", + tmpFile: { name: "temp", fd: undefined, removeCallback: () => undefined }, + isZipFile: true, + } as unknown as IPackage; + + let thrownError: Error | undefined; + try { + await downloadHelper.downloadFile( + testPackage.url, + testPackage, + stubILogger(sandbox), + createStubStatusView(), + ); + } catch (error) { + thrownError = error as Error; + } + + expect(thrownError?.message).to.equal("Temporary package file unavailable"); + }); + test("handleDownloadProgress test", () => { const mockProgress: IDownloadProgress = { totalBytes: 10, downloadedBytes: 5, - percentage: 50, }; const progressState: IDownloadProgressState = { downloadPercentage: 0, dots: 0, }; let testLogger = stubILogger(sandbox); - let mockStatusView: IStatusView = { - installingService: () => undefined, - serviceInstalled: () => undefined, - serviceInstallationFailed: () => undefined, - updateServiceDownloadingProgress: (_downloadPercentage: number) => undefined, - }; downloadHelper.handleDownloadProgress( mockProgress, progressState, testLogger, - mockStatusView, + createStubStatusView(), ); expect(progressState.downloadPercentage).to.equal(50); expect(progressState.dots).to.equal(10); diff --git a/extensions/mssql/test/unit/mainController.test.ts b/extensions/mssql/test/unit/mainController.test.ts index 4d63976b07..0c8b780edf 100644 --- a/extensions/mssql/test/unit/mainController.test.ts +++ b/extensions/mssql/test/unit/mainController.test.ts @@ -12,7 +12,6 @@ import MainController from "../../src/controllers/mainController"; import ConnectionManager from "../../src/controllers/connectionManager"; import { stubTelemetry, stubExtensionContext, stubMessageBoxes } from "./utils"; import * as Constants from "../../src/constants/constants"; -import { VscodeHttpClient } from "extension-toolkit/vscode"; import * as LocalizedConstants from "../../src/constants/locConstants"; import { SchemaDesignerWebviewManager } from "../../src/schemaDesigner/schemaDesignerWebviewManager"; import { CopilotChat } from "../../src/sharedInterfaces/copilotChat"; @@ -373,17 +372,30 @@ suite("MainController Tests", function () { }); test("Proxy settings are checked on initialization", async () => { - const httpHelperWarnSpy = sandbox.spy( - VscodeHttpClient.prototype, - "warnOnInvalidProxySettings", - ); + const httpConfiguration = vscode.workspace.getConfiguration("http"); + const originalProxy = httpConfiguration.inspect("proxy")?.globalValue; + messageBoxes.showWarningMessage.resolves(); + + try { + await httpConfiguration.update( + "proxy", + "localhost:8080", + vscode.ConfigurationTarget.Global, + ); - new MainController(context, connectionManager); + new MainController(context, connectionManager); - expect( - httpHelperWarnSpy.calledOnce, - "Expected warnOnInvalidProxySettings to be called once during initialization", - ).to.be.true; + expect( + messageBoxes.showWarningMessage.calledOnce, + "Expected a warning to be shown for a proxy setting without a protocol", + ).to.be.true; + } finally { + await httpConfiguration.update( + "proxy", + originalProxy, + vscode.ConfigurationTarget.Global, + ); + } }); suite("onNewQueryWithConnection Tests", () => { diff --git a/extensions/sql-database-projects/l10n/bundle.l10n.json b/extensions/sql-database-projects/l10n/bundle.l10n.json index 1689acf32b..cf71d90a80 100644 --- a/extensions/sql-database-projects/l10n/bundle.l10n.json +++ b/extensions/sql-database-projects/l10n/bundle.l10n.json @@ -279,9 +279,9 @@ "Download error": "Download error", "Download progress": "Download progress", "Downloading": "Downloading", - "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension.": "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension.", - "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1}": "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1}", - "Unable to read proxy agent options.": "Unable to read proxy agent options.", + "Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the SQL Database Projects extension.": "Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the SQL Database Projects extension.", + "Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the SQL Database Projects extension.": "Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the SQL Database Projects extension.", + "Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the SQL Database Projects extension.": "Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the SQL Database Projects extension.", "Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0}": "Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0}", "Downloading {0} nuget to get build DLLs ": "Downloading {0} nuget to get build DLLs ", "Downloading from {0} to {1}": "Downloading from {0} to {1}", diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 1a45c37941..66484a8edf 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -863,18 +863,17 @@ export const downloading = l10n.t("Downloading"); //#region proxy export const Proxy = { - missingProtocolWarning: (proxy: string) => + missingProtocolWarning: l10n.t( + "Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the SQL Database Projects extension.", + ), + unsupportedProtocolWarning: (protocol: string) => l10n.t( - "Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension.", - proxy, + "Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the SQL Database Projects extension.", + protocol, ), - unparseableWarning: (proxy: string, errorMessage: string) => - l10n.t( - "Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1}", - proxy, - errorMessage, - ), - unableToGetProxyAgentOptions: l10n.t("Unable to read proxy agent options."), + unparseableWarning: l10n.t( + "Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the SQL Database Projects extension.", + ), }; //#endregion diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 03c8daf9d0..f7a8b1e900 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -22,7 +22,11 @@ import * as constants from "../common/constants"; import { SqlDatabaseProjectProvider } from "../projectProvider/projectProvider"; import { ItemType } from "../sqldbproj"; import { FileNode } from "../models/tree/fileFolderTreeItem"; -import { VscodeHttpClient } from "extension-toolkit/vscode"; +import { + describeProxyConfigurationIssue, + getVscodeProxyConfigurationIssue, + ProxyConfigurationIssue, +} from "extension-toolkit/vscode"; /** * The main controller class that initializes the extension @@ -64,14 +68,25 @@ export default class MainController implements vscode.Disposable { } // Warn about invalid proxy settings early during activation - new VscodeHttpClient({ - messages: constants.Proxy, - }).warnOnInvalidProxySettings(); + this.warnOnInvalidProxySettings(); await this.initializeDatabaseProjects(); return new SqlDatabaseProjectProvider(this.projectsController); } + /** + * Surfaces a localized warning when the `http.proxy` setting cannot be used. + */ + private warnOnInvalidProxySettings(): void { + const issue = getVscodeProxyConfigurationIssue(); + if (!issue) { + return; + } + + void vscode.window.showWarningMessage(getProxyIssueMessage(issue)); + this._outputChannel.appendLine(describeProxyConfigurationIssue(issue)); + } + private async initializeDatabaseProjects(): Promise { // init commands this.context.subscriptions.push( @@ -364,3 +379,17 @@ export default class MainController implements vscode.Disposable { this.deactivate(); } } + +/** + * Maps a proxy configuration issue onto the extension's localized warning text. + */ +function getProxyIssueMessage(issue: ProxyConfigurationIssue): string { + switch (issue.kind) { + case "missing-protocol": + return constants.Proxy.missingProtocolWarning; + case "unsupported-protocol": + return constants.Proxy.unsupportedProtocolWarning(issue.protocol); + default: + return constants.Proxy.unparseableWarning; + } +} diff --git a/extensions/sql-database-projects/src/tools/buildHelper.ts b/extensions/sql-database-projects/src/tools/buildHelper.ts index 3123624057..c6283caaa0 100644 --- a/extensions/sql-database-projects/src/tools/buildHelper.ts +++ b/extensions/sql-database-projects/src/tools/buildHelper.ts @@ -195,13 +195,11 @@ export class BuildHelper { outputChannel: vscode.OutputChannel, ): Promise { try { - const httpClient = new VscodeHttpClient({ - messages: constants.Proxy, - }); + const httpClient = new VscodeHttpClient(); outputChannel.appendLine(constants.downloadingFromTo(downloadUrl, nugetPath)); let totalBytes: number | undefined; let printThreshold = 0.1; - const result = await httpClient.downloadFile(downloadUrl, nugetPath, { + const result = await httpClient.downloadToPath(downloadUrl, nugetPath, { onProgress: (progress) => { if (progress.downloadedBytes === 0) { totalBytes = progress.totalBytes; @@ -215,6 +213,7 @@ export class BuildHelper { if ( totalBytes !== undefined && + totalBytes > 0 && progress.downloadedBytes / totalBytes >= printThreshold ) { outputChannel.appendLine( diff --git a/extensions/sql-database-projects/test/buildHelper.test.ts b/extensions/sql-database-projects/test/buildHelper.test.ts index e18fba4413..e5dce16168 100644 --- a/extensions/sql-database-projects/test/buildHelper.test.ts +++ b/extensions/sql-database-projects/test/buildHelper.test.ts @@ -127,7 +127,7 @@ suite("BuildHelper: Build Helper tests", function (): void { // Treat all files as present and fail the test if a network download is attempted. sandbox.stub(utils, "exists").resolves(true); sandbox - .stub(VscodeHttpClient.prototype, "downloadFile") + .stub(VscodeHttpClient.prototype, "downloadToPath") .throws(new Error("download should not be called")); const testContext: TestContext = createContext(); @@ -173,7 +173,9 @@ suite("BuildHelper: Build Helper tests", function (): void { sandbox.stub(utils, "exists").resolves(false); // Make the actual HTTP download fail (simulates offline / proxy failure). - sandbox.stub(VscodeHttpClient.prototype, "downloadFile").rejects(new Error("ECONNREFUSED")); + sandbox + .stub(VscodeHttpClient.prototype, "downloadToPath") + .rejects(new Error("ECONNREFUSED")); // Capture the error message shown to the user. let shownMessage: string | undefined; @@ -267,7 +269,7 @@ suite("BuildHelper: Build Helper tests", function (): void { test("Returns true without downloading when all expected files already exist", async function (): Promise { // All files already present → download should never be called. sandbox.stub(utils, "exists").resolves(true); - const downloadSpy = sandbox.stub(VscodeHttpClient.prototype, "downloadFile"); + const downloadSpy = sandbox.stub(VscodeHttpClient.prototype, "downloadToPath"); const outputChannel = { appendLine: () => {}, diff --git a/localization/xliff/sql-database-projects.xlf b/localization/xliff/sql-database-projects.xlf index 4f2ce3cc3a..df12f329d3 100644 --- a/localization/xliff/sql-database-projects.xlf +++ b/localization/xliff/sql-database-projects.xlf @@ -547,11 +547,14 @@ Project was successfully updated. - - Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the SQL Database Projects extension. Error: {1} + + Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the SQL Database Projects extension. - - Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the SQL Database Projects extension. + + Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the SQL Database Projects extension. + + + Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the SQL Database Projects extension. Publish @@ -817,9 +820,6 @@ Unable to reach nuget.org. If you are behind a proxy or in an offline environment, you can manually place the required DLL files in the build directory: {0} - - Unable to read proxy agent options. - Unhandled item type during delete: '{0} diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 6c1186a71b..08faa97a6f 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -3078,6 +3078,9 @@ {0} is the error code {1} is the error message + + Fabric long-running operation response did not include a location header. + Failed @@ -5677,14 +5680,15 @@ Provisioning - - Proxy settings found, but encountered an error while parsing the URL: '{0}'. You may encounter connection issues while using the MSSQL extension. Error: {1} - {0} is the proxy URL -{1} is the error message + + Proxy settings found, but the URL could not be parsed. You may encounter connection issues while using the MSSQL extension. - - Proxy settings found, but without a protocol (e.g. http://): '{0}'. You may encounter connection issues while using the MSSQL extension. - {0} is the proxy URL + + Proxy settings found, but the protocol '{0}' is not supported; only http and https proxies can be used. You may encounter connection issues while using the MSSQL extension. + {0} is the proxy protocol + + + Proxy settings found, but without a protocol (e.g. http://). You may encounter connection issues while using the MSSQL extension. Public @@ -7748,9 +7752,6 @@ Unable to open Table Explorer: No target node provided. - - Unable to read proxy agent options. - Unable to retrieve the list of databases. You may not have permission to list databases on this server. diff --git a/packages/extension-toolkit/README.md b/packages/extension-toolkit/README.md index 7b4abc0f02..b000e85e6e 100644 --- a/packages/extension-toolkit/README.md +++ b/packages/extension-toolkit/README.md @@ -15,3 +15,44 @@ The package has three public entry points with a one-way dependency direction: test-only behavior is not included in the shipped runtime. Do not import from the package root. Use an explicit layer import instead. + +## HTTP client + +`extension-toolkit/base` exports a transport-neutral HTTP client. No Axios type +appears in any exported contract, so the underlying transport can be replaced +without a breaking change for callers. + +```ts +import { HttpClient } from "extension-toolkit/base"; + +const client = new HttpClient({ logger }); +const response = await client.get(url, { headers: { Authorization: token } }); +``` + +- `request`, `get`, and `postJson` return `IHttpResponse` with `data`, + `status`, `statusText`, `ok`, and a case-insensitive `headers` collection. +- `downloadToPath` stages the download in a sibling temporary file and only + replaces the destination after the transfer completes, so an existing file is + never truncated by a failed download. +- `downloadToFileDescriptor` writes to a caller-supplied descriptor and never + closes it; the caller keeps ownership. Descriptor `0` is a valid descriptor. +- Failures are reported as `HttpClientError` with a `kind` describing the + failure category (`network`, `timeout`, `cancelled`, `response-stream`, + `destination`, `progress-callback`, `proxy-configuration`). + +Authentication, JSON envelopes, logging, and user-facing messages are the +caller's responsibility. The client takes an optional `IHttpClientLogger` and +never localizes or surfaces UI. + +### Proxy support + +`HttpClient` resolves proxies from the environment (`HTTPS_PROXY`, `HTTP_PROXY`, +and `NO_PROXY`). `extension-toolkit/vscode` exports `VscodeHttpClient`, which +prefers the `http.proxy` setting and applies `http.proxyStrictSSL` to HTTPS +proxy certificate validation, falling back to the environment when `http.proxy` +is unset. + +Proxy strings are parsed once by `parseProxyConfiguration`, so validation and +execution always agree. Extensions that want to warn about a malformed setting +can call `getVscodeProxyConfigurationIssue()` and localize the returned +`ProxyConfigurationIssue` themselves. diff --git a/packages/extension-toolkit/package-lock.json b/packages/extension-toolkit/package-lock.json index 05f4b40d80..cdafd72ee4 100644 --- a/packages/extension-toolkit/package-lock.json +++ b/packages/extension-toolkit/package-lock.json @@ -13,12 +13,115 @@ "tunnel": "0.0.6" }, "devDependencies": { + "@types/chai": "^5.2.2", + "@types/mocha": "^10.0.10", "@types/node": "^22.13.14", + "@types/sinon": "^10.0.12", + "@types/sinon-chai": "^4.0.0", "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", + "chai": "^5.3.3", + "mocha": "^11.7.5", + "sinon": "^21.0.3", + "sinon-chai": "^4.0.1", "typescript": "^5.8.3" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha1-s3Znt7wYHBaHgiWbq0JHT79StVA=", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha1-ECk1fkTKkBphVYX20nc428iQhM0=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha1-XUDBUanmYHX+RSC+xAvM/lSTGWI=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "10.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha1-0ss08LzduVW2lxWFwvAzTmip5m0=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha1-3rJFPo8I3K566YxiaxPd2wFVkGw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha1-jpzZ4cNYH6azQaWu1ViOsoW+C0o=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha1-M0MRlx06BxIefrkbaEpgXn7qnL0=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha1-kfYpBejSPL1mIlMS8jlFSiO+v6A=", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -29,6 +132,34 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/sinon": { + "version": "10.0.20", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/sinon/-/sinon-10.0.20.tgz", + "integrity": "sha1-8Vhd6/TA2Z+ZOPQRHlR5+3SGUUY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinon-chai": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/sinon-chai/-/sinon-chai-4.0.0.tgz", + "integrity": "sha1-TQ5UChSYwOhWSQiKDH7THXYnAk8=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "*", + "@types/sinon": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha1-Sfcx2UU/UtZN159aVibBzxuBvqQ=", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tunnel": { "version": "0.0.1", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/tunnel/-/tunnel-0.0.1.tgz", @@ -58,6 +189,52 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha1-YCFu6kZNhkWXzigyAAc4oFiWUME=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha1-7dgDYornHATIWuegkG7a00tkiTc=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha1-JG9Q88p4oyQPbJl+ipvR6sSeSzg=", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha1-9kGhlrM1aQsQcL8AtudZP+wZC/c=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asynckit/-/asynckit-0.4.0.tgz", @@ -76,6 +253,30 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha1-C7oicf631Fiw0xrRNiWqpHVEMeI=", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha1-uqVZ7hTO1zRSIputcyZGfGH6vWA=", + "dev": true, + "license": "ISC" + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -89,6 +290,190 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha1-VoW5XrIJrJwMF3Rnd4ychN9Yupo=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chai/-/chai-5.3.3.tgz", + "integrity": "sha1-3T2pVeJwkWpL0/Yl9LkZmWrafgY=", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha1-qsTit3NKdAhnrrFr8CqtVWoeegE=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha1-G33NyzK4E4gBs+R4umpRyqiWSNo=", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha1-JCc2ERe3DMqNyJaA6tMrFXAZyvU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha1-e+N6TAPJruHs/oYqSiOyxwwgXTA=", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha1-DASwddsCy/5g3I5s8vVIaxo2CKo=", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha1-ctOmjVmMm9s68q0ehPIdiWq9TeM=", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", @@ -101,6 +486,21 @@ "node": ">= 0.8" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", @@ -118,6 +518,29 @@ } } }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha1-qkcte/Zg6xXzSU79UxyrfypwmDc=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha1-S3VtjXcKklcwCCXVKiws/5nDo0E=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -127,6 +550,16 @@ "node": ">=0.4.0" } }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/diff/-/diff-7.0.0.tgz", + "integrity": "sha1-P7NNOHzXbYA/buvqZ7kh2rAYKpo=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -141,6 +574,20 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha1-aWzi7Aqg5uqTo5f/zySqeEDIJ8s=", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha1-hAyIA7DYBH9P8M+WMXazLU7z7XI=", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", @@ -186,6 +633,56 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha1-ARo/aYVroYnf+n3I/M6Z0qh5A+U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha1-FLqDpdNz49MR5a/KKc9b+tllvzQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha1-TJKBnstwg1YeT0okCoa+UZj1Nvw=", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/flat/-/flat-5.0.2.tgz", + "integrity": "sha1-jKb+MyBp/6nTJMMnGYxZglnOskE=", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -206,6 +703,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha1-Mujp7Rtoo0l777msK2rfkqY4V28=", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/form-data/-/form-data-4.0.6.tgz", @@ -231,6 +745,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha1-T5RBKoLbMvNuOwuXQfipf+sDH34=", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -268,11 +792,33 @@ "node": ">= 0.4" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", - "license": "MIT", + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/glob/-/glob-10.5.0.tgz", + "integrity": "sha1-jsA1WRnNMzjChCiiPU8k7MX+c4w=", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -280,6 +826,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha1-lEdx/ZyByBJlxNaUGGDaBrtZR5s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", @@ -319,6 +875,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/he/-/he-1.2.0.tgz", + "integrity": "sha1-hK5l+n6vsWX922FWauFLrwVmTw8=", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -332,6 +898,142 @@ "node": ">= 6" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha1-8Rb4Bk/pCz94RKOJl8C3UFEmnx0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha1-0jE2LlOgf/Kw4Op/7QSRYf/RYoM=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha1-ReQuN/zPH0Dajl927iFRWEDAkoc=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha1-PybHaoCVk7Ur+i7LVxDtJ3m1Iqc=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha1-iDOp2Jq0rN5hiJQr0cU7Y5DtWoo=", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha1-0ZAFcqf3zwtfVAyDZz5gutNDZZI=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha1-VTIeswn+u8WcSAHZMackUqaB0oY=", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha1-P727lbRoOsn8eFER55LlWNSr1QM=", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha1-AJXPVtxbepp8CP9bGoeW7IrRfnY=", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha1-QQ/IoXtw5ZgBPfJXwkRrfzOD8Rk=", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -362,12 +1064,168 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha1-mwy5/LeAh/b9fqur4lEcTT1gV04=", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha1-eTibTrG7LQA6m7qH1JLyvTe9xls=", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha1-674imJ0Ey7lCSjYwcyBHZiTEGjM=", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", "license": "MIT" }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha1-4drMvnjQ0TiMoYxk/qOOPlfjcGs=", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha1-g8gxXGeFAF470CGDlBHJ4RDm2DQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha1-TxRxoBCCeob5TP2bByfjbSZ95QU=", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha1-UTvb4tO5XXdi6METfvoZXGxhtbM=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha1-eWCmaIiFlKByCxKpEdGnQqufEdI=", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha1-iFXFooma8HLWrAXRHkYEWtDcYF0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -377,6 +1235,278 @@ "node": ">=10" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha1-32+ENy8CcNxlzfYpE0mrekc9Tyo=", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha1-64WAFDX78qfuWPGeCSGwaPxplI0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha1-3voeBVyDv21Z6oBdjahiJU62psI=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha1-lSGIwcvVRgcOLdIND0HArgUwywQ=", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sinon": { + "version": "21.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sinon/-/sinon-21.1.2.tgz", + "integrity": "sha1-JASmADhT5vwwQwgl/SH+h2dfKb8=", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.3.2", + "@sinonjs/samsam": "^10.0.2", + "diff": "^8.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon-chai": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sinon-chai/-/sinon-chai-4.0.1.tgz", + "integrity": "sha1-9wAA/g5PSrfO6zcD0wU/iIbgOGs=", + "dev": true, + "license": "(BSD-2-Clause OR WTFPL)", + "peerDependencies": { + "chai": "^5.0.0 || ^6.0.0", + "sinon": ">=4.0.0" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/diff/-/diff-8.0.4.tgz", + "integrity": "sha1-T1uvMYi5skMRF7li6yC6Mw+t9pY=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha1-FPja7G2B5yIdKjV+Zoyrc728p5Q=", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha1-MfEoGzgyYwQ0gxwxDAHMzajL4AY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha1-zW/BfihQDP9WwbhsCn/UpUpzAFw=", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tunnel/-/tunnel-0.0.6.tgz", @@ -386,6 +1516,16 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha1-dkb7XxiHHPu3dJ5pvTmmOI63RQw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -406,6 +1546,237 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", + "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha1-9skjlbIUGv144qiJ6AyzOP6fykE=", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha1-VtwiNo7lcPrOG0mBmXXZuaXq0hQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha1-Z+FFz/UQpqaYS98RUpEdadLrnkM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha1-f0k00PfKjFb5UxSTndzS3ZHOHVU=", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha1-d53/5ryv7FlqcXLpgyiaWIZH+qo=", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha1-kJa87r+ZDSG7MfqVFuDt4pSnfTU=", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha1-8TH5ImkRrl2a04xDL+gJNmwjJes=", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha1-CCyyyJyf6GWaMRpTvWpNxTAdswQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha1-6Bj9ac5cz8tARZT4QpY79TFkzDc=", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha1-JpxxF9J7Ba0uU2gwqOyJXvnG0BA=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha1-nibGPTD1NEPpSJSVshBdN7Z6hdk=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha1-ApTrPe4FAo0x7hpfosVWpqrxChs=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/packages/extension-toolkit/package.json b/packages/extension-toolkit/package.json index 3ef23cf249..72c91bbe0f 100644 --- a/packages/extension-toolkit/package.json +++ b/packages/extension-toolkit/package.json @@ -41,17 +41,27 @@ ], "scripts": { "build": "tsc -p tsconfig.json", + "build:test": "tsc -p tsconfig.test.json", "watch": "tsc -watch -p tsconfig.json --preserveWatchOutput", - "lint": "eslint --quiet --cache ./src" + "lint": "eslint --quiet --cache ./src", + "test": "npm run build:test && mocha \"out/test/**/*.test.js\" --timeout 10000" }, "dependencies": { "axios": "^1.18.0", "tunnel": "0.0.6" }, "devDependencies": { + "@types/chai": "^5.2.2", + "@types/mocha": "^10.0.10", "@types/node": "^22.13.14", + "@types/sinon": "^10.0.12", + "@types/sinon-chai": "^4.0.0", "@types/tunnel": "0.0.1", "@types/vscode": "1.101.0", + "chai": "^5.3.3", + "mocha": "^11.7.5", + "sinon": "^21.0.3", + "sinon-chai": "^4.0.1", "typescript": "^5.8.3" } } diff --git a/packages/extension-toolkit/src/base/http/httpClient.ts b/packages/extension-toolkit/src/base/http/httpClient.ts index e8af660e8d..eb1135721b 100644 --- a/packages/extension-toolkit/src/base/http/httpClient.ts +++ b/packages/extension-toolkit/src/base/http/httpClient.ts @@ -3,578 +3,828 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as crypto from "crypto"; import * as fs from "fs"; -import * as http from "http"; -import * as https from "https"; -import { Readable } from "stream"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import { Readable, Transform, Writable } from "stream"; +import { pipeline } from "stream/promises"; import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import * as tunnel from "tunnel"; import { getErrorMessage } from "../common"; - -const UnableToGetProxyAgentOptionsMessage = "Unable to read proxy agent options."; -const HTTPS_PORT = 443; -const HTTP_PORT = 80; - -/** Optional logger contract used by the HTTP client for diagnostics and errors. */ -export interface IHttpClientLogger { - /** Writes a diagnostic message. */ - debug(message: string, ...args: unknown[]): void; - - /** Writes a warning message. */ - warn(message: string, ...args: unknown[]): void; - - /** Writes an error message. */ - error(message: string, ...args: unknown[]): void; - - /** - * Writes a message with explicit PII sanitization metadata. - */ - piiSanitized( - message: unknown, - objectsToSanitize: { name: string; objOrArray: unknown | unknown[] }[], - stringsToShorten: { name: string; value: string }[], - ...values: unknown[] - ): void; -} - -/** Localized proxy warning messages used by the HTTP client. */ -export interface IHttpClientMessages { - /** Builds a warning when a proxy is configured without a protocol. */ - missingProtocolWarning(proxy: string): string; - - /** Builds a warning when a proxy URL cannot be parsed. */ - unparseableWarning(proxy: string, errorMessage: string): string; - - /** Message used when a proxy agent cannot be constructed. */ - unableToGetProxyAgentOptions: string; -} - -/** Runtime integration points for proxy settings and warning presentation. */ -export interface IHttpClientDependencies { - /** Returns the configured proxy endpoint, if available. */ - getProxyConfig?: () => string | undefined; - - /** Returns whether proxy certificates should be validated. */ - getProxyStrictSSL?: () => boolean | undefined; - - /** Parses a URI and returns its scheme. */ - parseUriScheme?: (value: string) => string | undefined; - - /** Displays a warning message to the user. */ - showWarningMessage?: (message: string) => void; - - /** Localized proxy warning messages. */ - messages?: IHttpClientMessages; -} - -/** Progress payload for download callbacks. */ -export interface IDownloadProgress { - /** Number of bytes downloaded so far. */ - downloadedBytes: number; - - /** Total bytes, when known from response headers. */ - totalBytes?: number; - - /** Percentage in the range `[0, 100]`, when total bytes are known. */ - percentage?: number; -} - -/** Optional settings for file download operations. */ -export interface IDownloadFileOptions { - /** Receives progress updates while the response stream is being written. */ - onProgress?: (progress: IDownloadProgress) => void; -} - -/** Result returned by a completed download operation. */ -export interface IDownloadFileResult { - /** HTTP response status code. */ - status: number; - - /** Response headers from the download request. */ - headers: IHttpHeaders; -} - -/** HTTP header map with normalized string values. */ -export type IHttpHeaders = Record; - -/** Standardized HTTP response shape used by this toolkit. */ -export interface IHttpResponse { - /** Response payload. */ - data: TResponse; - - /** HTTP response status code. */ - status: number; - - /** HTTP status text, if provided by the transport layer. */ - statusText: string; - - /** Response headers. */ - headers: IHttpHeaders; +import { getErrorCode, HttpClientError } from "./httpErrors"; +import { createHttpHeaders, IHttpHeaders } from "./httpHeaders"; +import { + HttpRequestHeaders, + IDownloadOptions, + IDownloadResult, + IHttpClientLogger, + IHttpRequest, + IHttpRequestOptions, + IHttpResponse, +} from "./httpTypes"; +import { + createEnvironmentProxyResolver, + getProxyPort, + getRedactedProxyDescription, + IProxyAgentFactory, + IProxyAgentOptions, + IProxyConfiguration, + IProxyResolver, +} from "./proxy"; + +/** Construction options for {@link HttpClient}. */ +export interface IHttpClientOptions { + /** Optional logger for credential-free HTTP diagnostics. */ + readonly logger?: IHttpClientLogger; + + /** Resolves the proxy for each request. Defaults to the environment variable resolver. */ + readonly proxyResolver?: IProxyResolver; + + /** Creates proxy agents. Primarily an injection point for tests. */ + readonly proxyAgentFactory?: IProxyAgentFactory; } /** - * Shared HTTP client with proxy support, optional diagnostics, and stream downloads. + * Transport-neutral HTTP client with proxy support and staged file downloads. + * + * Behavior that callers can rely on: + * - Every HTTP status resolves, including 4xx and 5xx. Only transport, timeout, cancellation, + * destination, and stream failures reject, always with an {@link HttpClientError}. + * - Response generics are compile-time assertions only; no runtime schema validation is done. + * - Response header names are case-insensitive. + * - Proxy precedence is the host-provided proxy setting, then the environment, then direct. + * - Only `http:` and `https:` proxies are supported. + * - Requests are never retried automatically. */ export class HttpClient { + protected readonly logger?: IHttpClientLogger; + + private readonly _proxyResolver: IProxyResolver; + private readonly _proxyAgentFactory: IProxyAgentFactory; + /** * Creates an HTTP client. * - * @param logger Optional logger for diagnostics and warnings. - * @param dependencies Optional host-specific proxy and UI integrations. + * @param options Optional logger, proxy resolver, and proxy agent factory. */ - constructor( - protected readonly logger?: IHttpClientLogger, - private readonly dependencies: IHttpClientDependencies = {}, - ) {} + constructor(options: IHttpClientOptions = {}) { + this.logger = options.logger; + this._proxyResolver = options.proxyResolver ?? createEnvironmentProxyResolver(); + this._proxyAgentFactory = options.proxyAgentFactory ?? defaultProxyAgentFactory; + } /** - * Sends an HTTP GET request. + * Sends an HTTP request. This is the canonical request path; every other request helper + * delegates to it. * - * @param requestUrl Target URL. - * @param token Bearer token sent in the `Authorization` header. + * @param request Method, URL, headers, and optional body. + * @returns The response, including non-success statuses. + * @throws {HttpClientError} When the request fails, times out, or is cancelled. */ - public async makeGetRequest( - requestUrl: string, - token: string, + public async request( + request: IHttpRequest, ): Promise> { - const request = this.setupRequest(requestUrl, token); + const cancellation = createRequestCancellation(request.signal, request.timeoutMs); + let requestConfig: { config: AxiosRequestConfig; target: URL }; + try { + requestConfig = this.createRequestConfig(request, undefined, cancellation.signal); + } catch (error) { + cancellation.dispose(); + throw error; + } - const response: AxiosResponse = await this.get( - request.requestUrl, - request.config, - ); - this.logger?.piiSanitized( - "GET request ", - [ - { - name: "response", - objOrArray: - (response.data?.value as TResponse) ?? - (response.data as { value: TResponse }), - }, - ], - [], - request.requestUrl, + const { config, target } = requestConfig; + const startedAt = Date.now(); + + let response: AxiosResponse; + try { + response = await axios.request(config); + } catch (error) { + const failure = toRequestError(error, cancellation); + this.logger?.error( + `HTTP ${request.method} ${describeTarget(target)} failed after ${Date.now() - startedAt}ms: ${failure.kind}${failure.code ? ` (${failure.code})` : ""}.`, + ); + throw failure; + } finally { + cancellation.dispose(); + } + + this.logger?.debug( + `HTTP ${request.method} ${describeTarget(target)} responded ${response.status} in ${Date.now() - startedAt}ms.`, ); - return response as unknown as IHttpResponse; + + return toHttpResponse(response); } /** - * Sends an HTTP POST request. + * Sends an HTTP GET request. No content type is added automatically. * - * @param requestUrl Target URL. - * @param token Bearer token sent in the `Authorization` header. - * @param payload JSON payload to post. + * @param url Target URL. + * @param options Headers, cancellation signal, and timeout. */ - public async makePostRequest( - requestUrl: string, - token: string, - payload: TPayload, + public get( + url: string | URL, + options?: IHttpRequestOptions, ): Promise> { - const request = this.setupRequest(requestUrl, token); - - const response: AxiosResponse = await this.post( - request.requestUrl, - payload, - request.config, - ); - this.logger?.piiSanitized( - "POST request ", - [{ name: "response", objOrArray: response.data }], - [], - request.requestUrl, - ); - return response as unknown as IHttpResponse; + return this.request({ ...options, method: "GET", url }); } /** - * Downloads a URL to a path or an open file descriptor. - * The caller retains ownership of a supplied file descriptor. + * Sends an HTTP POST request with a JSON body. + * + * `Content-Type: application/json` and `Accept: application/json` are added only when the + * caller has not already supplied them. * - * @param requestUrl Target URL. - * @param destination Output path or open file descriptor. - * @param options Optional download settings including progress callback. + * @param url Target URL. + * @param body Payload to serialize as JSON. + * @param options Headers, cancellation signal, and timeout. */ - public async downloadFile( - requestUrl: string, - destination: string | number, - options?: IDownloadFileOptions, - ): Promise { - const destinationFd = - typeof destination === "string" ? fs.openSync(destination, "w") : destination; + public postJson( + url: string | URL, + body: TBody, + options?: IHttpRequestOptions, + ): Promise> { + return this.request({ + ...options, + method: "POST", + url, + body, + headers: withDefaultHeaders(options?.headers, { + "Content-Type": "application/json", + Accept: "application/json", + }), + }); + } + /** + * Downloads a URL into a file path. + * + * The response is staged in a temporary sibling file and moved over the destination only + * after the download completes successfully, so an existing file is never truncated by a + * non-success response, a stream failure, or a cancellation. Every descriptor and stream + * created here is owned and closed by this method. + * + * @param url Target URL. + * @param destinationPath Final path to write. + * @param options Headers, cancellation signal, timeout, and progress callback. + * @returns The response status and headers. Non-success responses leave the destination alone. + * @throws {HttpClientError} When the request, response stream, progress callback, or + * destination replacement fails. + */ + public async downloadToPath( + url: string | URL, + destinationPath: string, + options?: IDownloadOptions, + ): Promise { + const cancellation = createRequestCancellation(options?.signal, options?.timeoutMs); try { - return await this.downloadToFileDescriptor(requestUrl, destinationFd, options); - } finally { - if (typeof destination === "string") { - fs.closeSync(destinationFd); + const download = await this.requestDownload(url, options, cancellation); + if (!download.result.ok) { + download.response.data.destroy(); + return download.result; + } + + try { + emitInitialProgress(download.totalBytes, options); + } catch (error) { + download.response.data.destroy(); + throw error; + } + + const temporaryPath = createTemporarySiblingPath(destinationPath); + try { + const destinationStream = fs.createWriteStream(temporaryPath); + await this.pipeResponse( + download.response.data, + destinationStream, + download.totalBytes, + options, + cancellation, + ); + await waitForClose(destinationStream); + await replaceFile(temporaryPath, destinationPath, this.logger); + } catch (error) { + await removeQuietly(temporaryPath, this.logger); + throw error instanceof HttpClientError + ? error + : new HttpClientError( + "destination", + "Unable to replace the download destination.", + getErrorCode(error), + { cause: error }, + ); } + + return download.result; + } finally { + cancellation.dispose(); } } /** - * Validates proxy settings and emits warnings for invalid values. + * Downloads a URL into an already-open file descriptor. + * + * The descriptor remains owned by the caller: it is never closed by this method, on success + * or on failure. Non-success responses are returned without writing anything. + * + * @param url Target URL. + * @param destinationFd Open file descriptor to write into. + * @param options Headers, cancellation signal, timeout, and progress callback. + * @throws {HttpClientError} When the request, response stream, progress callback, or write + * fails. */ - public warnOnInvalidProxySettings(): void { - const proxy = this.loadProxyConfig(); - if (!proxy) { - return; - } - - let message = undefined; - let localizedMessage = undefined; - + public async downloadToFileDescriptor( + url: string | URL, + destinationFd: number, + options?: IDownloadOptions, + ): Promise { + const cancellation = createRequestCancellation(options?.signal, options?.timeoutMs); try { - const scheme = this.dependencies.parseUriScheme - ? this.dependencies.parseUriScheme(proxy) - : new URL(proxy).protocol; - - if (!scheme) { - message = `Proxy settings found, but without a protocol (e.g. http://): '${proxy}'. You may encounter connection issues while using this extension.`; - localizedMessage = this.dependencies.messages?.missingProtocolWarning(proxy); + const download = await this.requestDownload(url, options, cancellation); + if (!download.result.ok) { + download.response.data.destroy(); + return download.result; } - } catch (error) { - const errorMessage = getErrorMessage(error); - message = `Proxy settings found, but encountered an error while parsing the URL: '${proxy}'. You may encounter connection issues while using this extension. Error: ${errorMessage}`; - localizedMessage = this.dependencies.messages?.unparseableWarning(proxy, errorMessage); - } - if (message) { - if (localizedMessage) { - this.dependencies.showWarningMessage?.(localizedMessage); + try { + emitInitialProgress(download.totalBytes, options); + } catch (error) { + download.response.data.destroy(); + throw error; } - this.logger?.warn(message); - } - } - private setupRequest( - requestUrl: string, - token?: string, - ): { requestUrl: string; config: AxiosRequestConfig } { - const config = this.setupConfigAndProxyForRequest(requestUrl, token); - return { - requestUrl: this.constructRequestUrl(requestUrl, config), - config, - }; + await this.pipeResponse( + download.response.data, + createFileDescriptorWritable(destinationFd), + download.totalBytes, + options, + cancellation, + ); + + return download.result; + } finally { + cancellation.dispose(); + } } - private async downloadToFileDescriptor( - requestUrl: string, - destinationFd: number, - options?: IDownloadFileOptions, - ): Promise { - const request = this.setupRequest(requestUrl); - const requestConfig: AxiosRequestConfig = { - ...request.config, - responseType: "stream", - }; + private async requestDownload( + url: string | URL, + options?: IDownloadOptions, + cancellation?: IRequestCancellation, + ): Promise<{ + response: AxiosResponse; + result: IDownloadResult; + totalBytes: number | undefined; + }> { + const { config, target } = this.createRequestConfig( + { + method: "GET", + url, + headers: options?.headers, + }, + "stream", + cancellation?.signal, + ); let response: AxiosResponse; try { - response = await this.get(request.requestUrl, requestConfig); + response = await axios.request(config); } catch (error) { - throw new HttpDownloadError("request", error as NodeJS.ErrnoException); - } - - const totalBytes = this.getContentLength(response.headers["content-length"]); - let downloadedBytes = 0; - options?.onProgress?.({ - downloadedBytes, - totalBytes, - percentage: totalBytes === undefined ? undefined : 0, - }); - - if (response.status !== 200) { - response.data.destroy(); - return { - status: response.status, - headers: response.headers as unknown as IHttpHeaders, - }; + const failure = toRequestError(error, cancellation); + this.logger?.error( + `Download of ${describeTarget(target)} failed: ${failure.kind}${failure.code ? ` (${failure.code})` : ""}.`, + ); + throw failure; } - await new Promise((resolve, reject) => { - const destinationStream = fs.createWriteStream("", { - fd: destinationFd, - autoClose: false, - }); - let isSettled = false; + const headers = createHttpHeaders(toHeaderRecord(response.headers)); + const result: IDownloadResult = { + status: response.status, + statusText: response.statusText ?? "", + ok: isSuccessStatus(response.status), + headers, + }; - const rejectDownload = (error: NodeJS.ErrnoException) => { - if (isSettled) { - return; - } + this.logger?.debug(`Download of ${describeTarget(target)} responded ${response.status}.`); - isSettled = true; - response.data.destroy(); - destinationStream.destroy(); - reject(new HttpDownloadError("response", error)); - }; - - response.data.on("data", (data: Buffer) => { - downloadedBytes += data.length; - options?.onProgress?.({ - downloadedBytes, - totalBytes, - percentage: - totalBytes === undefined - ? undefined - : Math.min(100, (downloadedBytes / totalBytes) * 100), - }); - }); + return { response, result, totalBytes: parseContentLength(headers) }; + } - response.data.on("error", rejectDownload); - destinationStream.on("error", rejectDownload); + private async pipeResponse( + source: Readable, + destination: Writable, + totalBytes: number | undefined, + options?: IDownloadOptions, + cancellation?: IRequestCancellation, + ): Promise { + const onProgress = options?.onProgress; + let downloadedBytes = 0; - destinationStream.on("finish", () => { - if (isSettled) { - return; + const progress = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + downloadedBytes += chunk.length; + + if (onProgress) { + try { + onProgress({ downloadedBytes, totalBytes }); + } catch (error) { + callback( + new HttpClientError( + "progress-callback", + "The download progress callback threw an error.", + getErrorCode(error), + { cause: error }, + ), + ); + return; + } } - isSettled = true; - resolve(); - }); + callback(null, chunk); + }, + }); - response.data.pipe(destinationStream); + let sourceError: unknown; + source.on("error", (error) => { + sourceError = error; }); - return { - status: response.status, - headers: response.headers as unknown as IHttpHeaders, - }; - } + let destinationError: unknown; + destination.on("error", (error) => { + destinationError = error; + }); - private setupConfigAndProxyForRequest(requestUrl: string, token?: string): AxiosRequestConfig { - const headers: { "Content-Type": string; Authorization?: string } = { - "Content-Type": "application/json", - }; + try { + await pipeline(source, progress, destination, { signal: cancellation?.signal }); + } catch (error) { + throw toStreamError(error, destinationError, sourceError, cancellation); + } - if (token) { - headers.Authorization = `Bearer ${token}`; + if (totalBytes !== undefined && totalBytes > 0 && downloadedBytes !== totalBytes) { + throw new HttpClientError( + "response-stream", + `The download ended after ${downloadedBytes} bytes but ${totalBytes} bytes were expected.`, + "ERR_CONTENT_LENGTH_MISMATCH", + { cause: undefined }, + ); } + } + + private createRequestConfig( + request: IHttpRequest, + responseType?: "stream", + signal?: AbortSignal, + ): { config: AxiosRequestConfig; target: URL } { + const target = parseTargetUrl(request.url); const config: AxiosRequestConfig = { - headers, + method: request.method, + url: typeof request.url === "string" ? request.url : request.url.toString(), + headers: { ...request.headers }, validateStatus: () => true, }; - const proxy = this.loadProxyConfig(); + if (request.body !== undefined) { + config.data = request.body; + } - if (proxy) { - this.logger?.debug( - "Proxy endpoint found in environment variables or workspace configuration.", - ); - config.proxy = false; + if (signal) { + config.signal = signal; + } - const agent = this.createProxyAgent( - requestUrl, - proxy, - this.dependencies.getProxyStrictSSL?.(), - ); - if (requestUrl.startsWith("https")) { - config.httpsAgent = agent.agent; - } else { - config.httpAgent = agent.agent; - } + if (responseType) { + config.responseType = responseType; } - return config; - } - private get( - requestUrl: string, - config: AxiosRequestConfig, - ): Promise> { - return axios.get(requestUrl, config); - } + this.applyProxy(config, target); - private post( - requestUrl: string, - payload: TPayload, - config: AxiosRequestConfig, - ): Promise> { - return axios.post(requestUrl, payload, config); + return { config, target }; } - private loadProxyConfig(): string | undefined { - let proxy = this.dependencies.getProxyConfig?.(); + private applyProxy(config: AxiosRequestConfig, target: URL): void { + // Proxy selection is owned entirely by the resolver, so the transport's own environment + // handling is always disabled. + config.proxy = false; + + let proxy: IProxyConfiguration | undefined; + try { + proxy = this._proxyResolver.resolve(target); + } catch (error) { + const code = getErrorCode(error); + this.logger?.error( + `Unable to resolve the configured proxy${code ? ` (${code})` : ""}.`, + ); + throw new HttpClientError( + "proxy-configuration", + "Unable to resolve the configured proxy.", + getErrorCode(error), + { cause: error }, + ); + } if (!proxy) { - this.logger?.debug( - "Workspace HTTP config didn't contain a proxy endpoint. Checking environment variables.", + return; + } + + this.logger?.debug( + `Routing request through ${proxy.source} proxy ${getRedactedProxyDescription(proxy.url)}.`, + ); + + let agent: unknown; + try { + agent = this.createProxyAgent(target, proxy); + } catch (error) { + if (error instanceof HttpClientError) { + throw error; + } + + const code = getErrorCode(error); + this.logger?.error( + `Unable to construct the configured proxy agent${code ? ` (${code})` : ""}.`, + ); + throw new HttpClientError( + "proxy-configuration", + "Unable to construct the configured proxy agent.", + code, + { cause: error }, ); - proxy = this.loadEnvironmentProxyValue(); } - return proxy; + if (target.protocol === "https:") { + config.httpsAgent = agent; + } else { + config.httpAgent = agent; + } } - private constructRequestUrl(requestUrl: string, config: AxiosRequestConfig): string { - if (!config.proxy) { - const parsedRequestUrl = new URL(requestUrl); - const port = - parsedRequestUrl.port || - (parsedRequestUrl.protocol?.startsWith("https") ? HTTPS_PORT : HTTP_PORT); + private createProxyAgent(target: URL, proxy: IProxyConfiguration): unknown { + const isHttpsProxy = proxy.url.protocol === "https:"; + const credentials = + proxy.url.username || proxy.url.password + ? `${decodeProxyCredential(proxy.url.username)}:${decodeProxyCredential(proxy.url.password)}` + : undefined; + + const options: IProxyAgentOptions = { + proxy: { + host: proxy.url.hostname, + port: getProxyPort(proxy.url), + ...(credentials ? { proxyAuth: credentials } : {}), + // Applies to the proxy connection only; the destination certificate is always validated. + ...(isHttpsProxy ? { rejectUnauthorized: proxy.rejectUnauthorized } : {}), + }, + }; - return `${parsedRequestUrl.protocol}//${parsedRequestUrl.hostname}:${port}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; + if (target.protocol === "https:") { + return isHttpsProxy + ? this._proxyAgentFactory.httpsOverHttps(options) + : this._proxyAgentFactory.httpsOverHttp(options); } - return requestUrl; + + return isHttpsProxy + ? this._proxyAgentFactory.httpOverHttps(options) + : this._proxyAgentFactory.httpOverHttp(options); + } +} + +const defaultProxyAgentFactory: IProxyAgentFactory = { + httpOverHttp: (options) => tunnel.httpOverHttp(toAgentOptions(options)), + httpOverHttps: (options) => tunnel.httpOverHttps(toAgentOptions(options)), + httpsOverHttp: (options) => tunnel.httpsOverHttp(toAgentOptions(options)), + httpsOverHttps: (options) => tunnel.httpsOverHttps(toAgentOptions(options)), +}; + +function toAgentOptions(options: IProxyAgentOptions): tunnel.HttpsOverHttpsOptions { + const proxy: tunnel.HttpsProxyOptions & { rejectUnauthorized?: boolean } = { + host: options.proxy.host, + port: options.proxy.port, + }; + + if (options.proxy.proxyAuth) { + proxy.proxyAuth = options.proxy.proxyAuth; } - private loadEnvironmentProxyValue(): string | undefined { - const HTTP_PROXY = "HTTP_PROXY"; - const HTTPS_PROXY = "HTTPS_PROXY"; + if (options.proxy.rejectUnauthorized !== undefined) { + proxy.rejectUnauthorized = options.proxy.rejectUnauthorized; + } - if (!process) { - this.logger?.debug( - "No process object found, unable to read environment variables for proxy.", - ); - return undefined; - } + return { proxy }; +} - if (process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]) { - this.logger?.debug("Loading proxy value from HTTP_PROXY environment variable."); - return process.env[HTTP_PROXY] || process.env[HTTP_PROXY.toLowerCase()]; - } else if (process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]) { - this.logger?.debug("Loading proxy value from HTTPS_PROXY environment variable."); - return process.env[HTTPS_PROXY] || process.env[HTTPS_PROXY.toLowerCase()]; - } +function toHttpResponse(response: AxiosResponse): IHttpResponse { + return { + data: response.data, + status: response.status, + statusText: response.statusText ?? "", + ok: isSuccessStatus(response.status), + headers: createHttpHeaders(toHeaderRecord(response.headers)), + }; +} - this.logger?.debug( - "No proxy value found in either HTTPS_PROXY or HTTP_PROXY environment variables.", +function toHeaderRecord(headers: unknown): Record { + if (typeof headers !== "object" || headers === null) { + return {}; + } + + const record: Record = {}; + for (const [name, value] of Object.entries(headers as Record)) { + record[name] = value; + } + + return record; +} + +function isSuccessStatus(status: number): boolean { + return status >= 200 && status < 300; +} + +function parseTargetUrl(url: string | URL): URL { + if (url instanceof URL) { + return url; + } + + try { + return new URL(url); + } catch (error) { + throw new HttpClientError( + "network", + "The request URL is not a valid absolute URL.", + "ERR_INVALID_URL", + { cause: error }, ); - return undefined; } +} - private createProxyAgent( - requestUrl: string, - proxy: string, - proxyStrictSSL?: boolean, - ): ProxyAgent { - const agentOptions = this.getProxyAgentOptions(new URL(requestUrl), proxy, proxyStrictSSL); - if (!agentOptions || !agentOptions.host || !agentOptions.port) { - this.logger?.error("Unable to read proxy agent options to create proxy agent."); - throw new Error( - this.dependencies.messages?.unableToGetProxyAgentOptions ?? - UnableToGetProxyAgentOptionsMessage, - ); +function describeTarget(target: URL): string { + return `${target.protocol}//${target.host}${target.pathname}`; +} + +function withDefaultHeaders( + headers: HttpRequestHeaders | undefined, + defaults: Record, +): HttpRequestHeaders { + const merged: Record = { ...headers }; + const supplied = new Set(Object.keys(merged).map((name) => name.toLowerCase())); + + for (const [name, value] of Object.entries(defaults)) { + if (!supplied.has(name.toLowerCase())) { + merged[name] = value; } + } - const tunnelOptions: tunnel.HttpsOverHttpsOptions = { - proxy: { - host: agentOptions.host, - port: Number(agentOptions.port), - ...(agentOptions.auth ? { proxyAuth: agentOptions.auth } : {}), - }, - }; + return merged; +} + +function isAbortError(error: unknown): boolean { + if (axios.isCancel(error)) { + return true; + } + + const code = getErrorCode(error); + if (code === "ERR_CANCELED" || code === "ABORT_ERR") { + return true; + } + + return (error as { name?: unknown } | undefined)?.name === "AbortError"; +} + +interface IRequestCancellation { + readonly signal: AbortSignal | undefined; + readonly timedOut: boolean; + dispose(): void; +} - const isHttpsRequest = requestUrl.startsWith("https"); - const isHttpsProxy = proxy.startsWith("https"); +function createRequestCancellation( + callerSignal: AbortSignal | undefined, + timeoutMs: number | undefined, +): IRequestCancellation { + if (timeoutMs === undefined || timeoutMs === 0) { return { - agent: this.createTunnelingAgent(isHttpsRequest, isHttpsProxy, tunnelOptions), + signal: callerSignal, + timedOut: false, + dispose: () => undefined, }; } - private createTunnelingAgent( - isHttpsRequest: boolean, - isHttpsProxy: boolean, - tunnelOptions: tunnel.HttpsOverHttpsOptions, - ): http.Agent | https.Agent { - if (isHttpsRequest && isHttpsProxy) { - this.logger?.debug("Creating https request over https proxy tunneling agent"); - return tunnel.httpsOverHttps(tunnelOptions); - } else if (isHttpsRequest && !isHttpsProxy) { - this.logger?.debug("Creating https request over http proxy tunneling agent"); - return tunnel.httpsOverHttp(tunnelOptions); - } else if (!isHttpsRequest && isHttpsProxy) { - this.logger?.debug("Creating http request over https proxy tunneling agent"); - return tunnel.httpOverHttps(tunnelOptions); - } + const controller = new AbortController(); + let timedOut = false; + const abortFromCaller = () => controller.abort(callerSignal?.reason); - this.logger?.debug("Creating http request over http proxy tunneling agent"); - return tunnel.httpOverHttp(tunnelOptions); + if (callerSignal?.aborted) { + abortFromCaller(); + } else { + callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); } - private getProxyAgentOptions( - requestUrl: URL, - proxy?: string, - strictSSL?: boolean, - ): ProxyAgentOptions | undefined { - const proxyUrl = proxy || this.getSystemProxyUrl(requestUrl); + const timeout = setTimeout( + () => { + if (!controller.signal.aborted) { + timedOut = true; + controller.abort(new Error("The HTTP operation timed out.")); + } + }, + Math.max(0, timeoutMs), + ); + + return { + signal: controller.signal, + get timedOut(): boolean { + return timedOut; + }, + dispose(): void { + clearTimeout(timeout); + callerSignal?.removeEventListener("abort", abortFromCaller); + }, + }; +} - if (!proxyUrl) { - return undefined; - } +function toRequestError(error: unknown, cancellation?: IRequestCancellation): HttpClientError { + if (error instanceof HttpClientError) { + return error; + } - const proxyEndpoint = new URL(proxyUrl); - if (!/^https?:$/.test(proxyEndpoint.protocol)) { - return undefined; - } + const code = getErrorCode(error); - const auth = - proxyEndpoint.username || proxyEndpoint.password - ? `${proxyEndpoint.username}:${proxyEndpoint.password}` - : undefined; + if (cancellation?.timedOut) { + return new HttpClientError("timeout", "The HTTP request timed out.", code, { + cause: error, + }); + } - return { - host: proxyEndpoint.hostname, - port: proxyEndpoint.port - ? Number(proxyEndpoint.port) - : proxyEndpoint.protocol === "https:" - ? HTTPS_PORT - : HTTP_PORT, - auth, - rejectUnauthorized: strictSSL !== false, - }; + if (isAbortError(error) || cancellation?.signal?.aborted) { + return new HttpClientError("cancelled", "The HTTP request was cancelled.", code, { + cause: error, + }); } - private getSystemProxyUrl(requestUrl: URL): string | undefined { - if (requestUrl.protocol === "http:") { - return process.env.HTTP_PROXY || process.env.http_proxy || undefined; - } else if (requestUrl.protocol === "https:") { - return ( - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - undefined - ); - } + if (code === "ECONNABORTED" || code === "ETIMEDOUT" || code === "ERR_TIMEOUT") { + return new HttpClientError("timeout", "The HTTP request timed out.", code, { + cause: error, + }); + } + + return new HttpClientError("network", "The HTTP request failed.", code, { cause: error }); +} + +function toStreamError( + error: unknown, + destinationError: unknown, + sourceError: unknown, + cancellation?: IRequestCancellation, +): HttpClientError { + if (error instanceof HttpClientError) { + return error; + } + + const code = getErrorCode(error); + + if (cancellation?.timedOut) { + return new HttpClientError("timeout", "The download timed out.", code, { cause: error }); + } + + if (isAbortError(error) || cancellation?.signal?.aborted) { + return new HttpClientError("cancelled", "The download was cancelled.", code, { + cause: error, + }); + } + + if (code === "ECONNABORTED" || code === "ETIMEDOUT") { + return new HttpClientError("timeout", "The download timed out.", code, { cause: error }); + } + + // `pipeline` destroys the destination with the source's error, so a destination failure is + // only genuine when the source did not fail with the same error first. + if (destinationError !== undefined && error === destinationError && error !== sourceError) { + return new HttpClientError( + "destination", + "Unable to write to the download destination.", + code, + { cause: error }, + ); + } + + return new HttpClientError("response-stream", "The download response stream failed.", code, { + cause: error, + }); +} + +function emitInitialProgress(totalBytes: number | undefined, options?: IDownloadOptions): void { + if (!options?.onProgress) { + return; + } + try { + options.onProgress({ downloadedBytes: 0, totalBytes }); + } catch (error) { + throw new HttpClientError( + "progress-callback", + "The download progress callback threw an error.", + getErrorCode(error), + { cause: error }, + ); + } +} + +function parseContentLength(headers: IHttpHeaders): number | undefined { + const raw = headers.get("content-length"); + if (raw === undefined || !/^\d+$/.test(raw.trim())) { return undefined; } - private getContentLength(header: unknown): number | undefined { - if (Array.isArray(header)) { - return this.getContentLength(header[0]); - } + const value = Number(raw.trim()); + if (!Number.isSafeInteger(value)) { + return undefined; + } + + return value; +} - const value = typeof header === "number" ? header : Number.parseInt(`${header}`, 10); - return Number.isFinite(value) && value > 0 ? value : undefined; +function decodeProxyCredential(value: string): string { + try { + return decodeURIComponent(value); + } catch (error) { + throw new HttpClientError( + "proxy-configuration", + "Unable to decode the configured proxy credentials.", + getErrorCode(error), + { cause: error }, + ); } } -interface ProxyAgent { - agent: http.Agent | https.Agent; +function createFileDescriptorWritable(destinationFd: number): Writable { + return new Writable({ + write(chunk: Buffer, _encoding, callback) { + fs.write(destinationFd, chunk, 0, chunk.length, null, (error) => { + callback(error ?? null); + }); + }, + }); } -interface ProxyAgentOptions { - auth: string | undefined; - host?: string | null; - port?: string | number | null; - rejectUnauthorized: boolean; +function createTemporarySiblingPath(destinationPath: string): string { + const directory = path.dirname(destinationPath); + const fileName = path.basename(destinationPath); + return path.join(directory, `.${fileName}.${crypto.randomBytes(6).toString("hex")}.download`); } -/** Error raised by `downloadFile` when request or response streaming fails. */ -export class HttpDownloadError extends Error { - /** - * Creates a download error with phase metadata. - * - * @param phase Whether the failure happened while requesting or streaming. - * @param innerError The underlying Node.js error. - */ - constructor( - public phase: "request" | "response", - public innerError: NodeJS.ErrnoException, - ) { - super(innerError.message); +async function waitForClose(stream: fs.WriteStream): Promise { + if (stream.closed) { + return; + } + + await new Promise((resolve) => { + stream.once("close", resolve); + }); +} + +async function replaceFile( + temporaryPath: string, + destinationPath: string, + logger?: IHttpClientLogger, +): Promise { + const backupPath = `${destinationPath}.${crypto.randomBytes(6).toString("hex")}.bak`; + let hasBackup = false; + + try { + await fsPromises.rename(destinationPath, backupPath); + hasBackup = true; + } catch (error) { + if (getErrorCode(error) !== "ENOENT") { + throw error; + } + } + + try { + await fsPromises.rename(temporaryPath, destinationPath); + } catch (error) { + if (hasBackup) { + try { + await fsPromises.rename(backupPath, destinationPath); + } catch (restoreError) { + logger?.error( + `Unable to restore the previous download destination: ${getErrorMessage(restoreError)}`, + ); + } + } + throw error; + } + + if (hasBackup) { + try { + await fsPromises.rm(backupPath, { force: true }); + } catch (cleanupError) { + logger?.warn( + `Unable to remove the replaced download backup file: ${getErrorMessage(cleanupError)}`, + ); + } + } +} + +async function removeQuietly(filePath: string, logger?: IHttpClientLogger): Promise { + try { + await fsPromises.rm(filePath, { force: true }); + } catch (error) { + logger?.warn(`Unable to remove the partial download file: ${getErrorMessage(error)}`); } } diff --git a/packages/extension-toolkit/src/base/http/httpErrors.ts b/packages/extension-toolkit/src/base/http/httpErrors.ts new file mode 100644 index 0000000000..d11f7bcaa9 --- /dev/null +++ b/packages/extension-toolkit/src/base/http/httpErrors.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Categories of failures raised by the HTTP client. + * + * HTTP status codes never produce an error; they are returned to the caller instead. + */ +export type HttpClientErrorKind = + /** The request could not be completed because of a transport failure. */ + | "network" + /** The request or the response stream exceeded the configured timeout. */ + | "timeout" + /** The caller aborted the request through an `AbortSignal`. */ + | "cancelled" + /** The response body failed while being read. */ + | "response-stream" + /** The download destination could not be opened, written, or replaced. */ + | "destination" + /** A caller-supplied progress callback threw. */ + | "progress-callback" + /** The configured proxy could not be parsed or applied. */ + | "proxy-configuration"; + +/** Error raised by the HTTP client for transport, streaming, and destination failures. */ +export class HttpClientError extends Error { + /** Discriminator used by callers to translate this error into a domain-specific error. */ + public override readonly name = "HttpClientError"; + + /** + * Creates an HTTP client error. + * + * @param kind Category of the failure. + * @param message Credential-free description of the failure. + * @param code Underlying error code, when the transport reported one. + * @param options Wraps the original failure as the standard error `cause`. + */ + constructor( + public readonly kind: HttpClientErrorKind, + message: string, + public readonly code: string | undefined, + options: { cause: unknown }, + ) { + super(message, options); + } +} + +/** + * Extracts a string error code from an arbitrary thrown value, when one is present. + * + * @param error Value thrown by the transport or the file system. + */ +export function getErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} diff --git a/packages/extension-toolkit/src/base/http/httpHeaders.ts b/packages/extension-toolkit/src/base/http/httpHeaders.ts new file mode 100644 index 0000000000..fbf83f0fdf --- /dev/null +++ b/packages/extension-toolkit/src/base/http/httpHeaders.ts @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Read-only view over HTTP response headers. + * + * Header names are matched case-insensitively, so `get("Location")` and `get("location")` + * return the same value. Repeated headers (for example `set-cookie`) keep every value; + * use {@link IHttpHeaders.getAll} to read them all. + */ +export interface IHttpHeaders { + /** + * Returns the first value for the given header name, or `undefined` when the header is absent. + * + * @param name Case-insensitive header name. + */ + get(name: string): string | undefined; + + /** + * Returns every value recorded for the given header name, preserving order. + * + * @param name Case-insensitive header name. + */ + getAll(name: string): readonly string[]; + + /** + * Returns whether at least one value is recorded for the given header name. + * + * @param name Case-insensitive header name. + */ + has(name: string): boolean; + + /** Iterates the header entries as `[name, values]` pairs using the originally supplied casing. */ + entries(): IterableIterator; +} + +/** Source shape accepted when building {@link HttpHeaders}. */ +export type HttpHeadersInit = + | Readonly> + | Iterable; + +/** + * Case-insensitive, multi-value HTTP header collection. + * + * Values that cannot be represented as a header string (`undefined`, `null`, objects) are + * omitted. Numeric and boolean values are converted to their string representation. + */ +export class HttpHeaders implements IHttpHeaders { + private readonly _entries = new Map(); + + /** + * Creates a header collection. + * + * @param init Optional header record or iterable of `[name, value]` pairs. + */ + constructor(init?: HttpHeadersInit) { + if (!init) { + return; + } + + if (isIterableInit(init)) { + for (const [name, value] of init) { + this.append(name, value); + } + return; + } + + for (const name of Object.keys(init)) { + this.append(name, init[name]); + } + } + + /** + * Adds a value for the given header name, keeping any previously recorded values. + * Values that cannot be represented as a header string are ignored. + * + * @param name Header name. + * @param value Header value or array of values. + */ + public append(name: string, value: unknown): void { + const normalizedValues = normalizeHeaderValue(value); + if (normalizedValues.length === 0) { + return; + } + + const key = name.toLowerCase(); + const existing = this._entries.get(key); + if (existing) { + existing.values.push(...normalizedValues); + return; + } + + this._entries.set(key, { name, values: normalizedValues }); + } + + /** + * Returns the first value for the given header name. + * + * @param name Case-insensitive header name. + */ + public get(name: string): string | undefined { + return this._entries.get(name.toLowerCase())?.values[0]; + } + + /** + * Returns every value for the given header name. + * + * @param name Case-insensitive header name. + */ + public getAll(name: string): readonly string[] { + const values = this._entries.get(name.toLowerCase())?.values; + return values ? [...values] : []; + } + + /** + * Returns whether the header is present. + * + * @param name Case-insensitive header name. + */ + public has(name: string): boolean { + return this._entries.has(name.toLowerCase()); + } + + /** Iterates the header entries as `[name, values]` pairs. */ + public *entries(): IterableIterator { + for (const entry of this._entries.values()) { + yield [entry.name, [...entry.values]] as const; + } + } +} + +/** + * Creates an {@link IHttpHeaders} instance from a header record or iterable. + * + * @param init Optional header record or iterable of `[name, value]` pairs. + */ +export function createHttpHeaders(init?: HttpHeadersInit): IHttpHeaders { + return new HttpHeaders(init); +} + +function isIterableInit(init: HttpHeadersInit): init is Iterable { + return typeof (init as Iterable)[Symbol.iterator] === "function"; +} + +function normalizeHeaderValue(value: unknown): string[] { + if (value === undefined || value === null) { + return []; + } + + if (Array.isArray(value)) { + return value.flatMap((entry) => normalizeHeaderValue(entry)); + } + + switch (typeof value) { + case "string": + return [value]; + case "number": + return Number.isFinite(value) ? [String(value)] : []; + case "boolean": + case "bigint": + return [String(value)]; + default: + return []; + } +} diff --git a/packages/extension-toolkit/src/base/http/httpTypes.ts b/packages/extension-toolkit/src/base/http/httpTypes.ts new file mode 100644 index 0000000000..66831ff73f --- /dev/null +++ b/packages/extension-toolkit/src/base/http/httpTypes.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IHttpHeaders } from "./httpHeaders"; + +/** HTTP methods supported by the client. */ +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + +/** Request headers supplied by the caller. */ +export type HttpRequestHeaders = Readonly>; + +/** Options shared by every request helper. */ +export interface IHttpRequestOptions { + /** + * Headers sent with the request. Bearer authentication is supplied here, for example + * `{ Authorization: \`Bearer ${token}\` }`. + */ + readonly headers?: HttpRequestHeaders; + + /** Signal used to cancel the request; cancellation rejects with a `cancelled` error. */ + readonly signal?: AbortSignal; + + /** Maximum time to wait for the response, in milliseconds. */ + readonly timeoutMs?: number; +} + +/** + * A fully described HTTP request. + * + * @template TBody Type of the serialized request body. + */ +export interface IHttpRequest extends IHttpRequestOptions { + /** HTTP method. */ + readonly method: HttpMethod; + + /** Target URL. The URL is sent to the transport unchanged. */ + readonly url: string | URL; + + /** Request body. Omit for methods that do not carry a payload. */ + readonly body?: TBody; +} + +/** + * A completed HTTP response. + * + * Every HTTP status resolves, including 4xx and 5xx; inspect {@link IHttpResponse.ok} or + * {@link IHttpResponse.status} to detect failures. + * + * @template TResponse Compile-time assertion about the payload shape. The client performs no + * runtime validation of the response body. + */ +export interface IHttpResponse { + /** Deserialized response payload. */ + readonly data: TResponse; + + /** HTTP status code. */ + readonly status: number; + + /** HTTP status text reported by the server. */ + readonly statusText: string; + + /** `true` when the status is in the 200-299 range. */ + readonly ok: boolean; + + /** Case-insensitive response headers. */ + readonly headers: IHttpHeaders; +} + +/** Progress reported while a download is being written. */ +export interface IDownloadProgress { + /** Number of bytes received so far. */ + readonly downloadedBytes: number; + + /** + * Total number of bytes to expect. + * + * `undefined` means the length is unknown; `0` means the response is known to be empty. + */ + readonly totalBytes: number | undefined; +} + +/** Options accepted by the download helpers. */ +export interface IDownloadOptions extends IHttpRequestOptions { + /** + * Receives progress updates while the response body is written. + * + * An exception thrown from this callback fails the download with a `progress-callback` error. + */ + readonly onProgress?: (progress: IDownloadProgress) => void; +} + +/** Result of a completed download request. */ +export interface IDownloadResult { + /** HTTP status code. */ + readonly status: number; + + /** HTTP status text reported by the server. */ + readonly statusText: string; + + /** `true` when the status is in the 200-299 range and the destination was written. */ + readonly ok: boolean; + + /** Case-insensitive response headers. */ + readonly headers: IHttpHeaders; +} + +/** Minimal logging surface used for HTTP diagnostics. */ +export interface IHttpClientLogger { + /** Writes a diagnostic message. */ + debug(message: string, ...args: unknown[]): void; + + /** Writes a warning message. */ + warn(message: string, ...args: unknown[]): void; + + /** Writes an error message. */ + error(message: string, ...args: unknown[]): void; +} diff --git a/packages/extension-toolkit/src/base/http/index.ts b/packages/extension-toolkit/src/base/http/index.ts index 0ed0358766..3f9dfca9e7 100644 --- a/packages/extension-toolkit/src/base/http/index.ts +++ b/packages/extension-toolkit/src/base/http/index.ts @@ -4,3 +4,7 @@ *--------------------------------------------------------------------------------------------*/ export * from "./httpClient"; +export * from "./httpErrors"; +export * from "./httpHeaders"; +export * from "./httpTypes"; +export * from "./proxy"; diff --git a/packages/extension-toolkit/src/base/http/proxy.ts b/packages/extension-toolkit/src/base/http/proxy.ts new file mode 100644 index 0000000000..67d31e3eb2 --- /dev/null +++ b/packages/extension-toolkit/src/base/http/proxy.ts @@ -0,0 +1,371 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as http from "http"; + +/** Default port used when an `http:` proxy URL omits one. */ +const DEFAULT_HTTP_PORT = 80; + +/** Default port used when an `https:` proxy URL omits one. */ +const DEFAULT_HTTPS_PORT = 443; + +const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//; + +/** Where a resolved proxy came from. */ +export type ProxySource = "vscode" | "environment"; + +/** A proxy that should be used for a specific request. */ +export interface IProxyConfiguration { + /** Parsed proxy endpoint. Only `http:` and `https:` are supported. */ + readonly url: URL; + + /** + * Whether the certificate presented by an `https:` proxy must be valid. + * + * This controls the connection to the proxy only; the destination server's certificate is + * always validated. + */ + readonly rejectUnauthorized: boolean; + + /** Where the proxy setting was read from. */ + readonly source: ProxySource; +} + +/** + * Resolves the proxy to use for a request. + * + * Implementations may throw when a configured proxy value is invalid; the HTTP client converts + * such failures into a `proxy-configuration` error. + */ +export interface IProxyResolver { + /** + * Returns the proxy to use for the given target, or `undefined` for a direct connection. + * + * @param target Absolute URL of the request. + */ + resolve(target: URL): IProxyConfiguration | undefined; +} + +/** Reason a configured proxy value could not be used. */ +export type ProxyConfigurationIssue = + /** The value has no scheme, for example `proxy.example.com:3128`. */ + | { readonly kind: "missing-protocol" } + /** The value uses a scheme other than `http:` or `https:`, for example `socks5:`. */ + | { + readonly kind: "unsupported-protocol"; + readonly protocol: string; + } + /** The value could not be parsed as a URL. */ + | { + readonly kind: "invalid-url"; + readonly error: unknown; + }; + +/** Successful or failed result of parsing a proxy setting. */ +export type ProxyConfigurationParseResult = + | { readonly ok: true; readonly url: URL } + | { readonly ok: false; readonly issue: ProxyConfigurationIssue }; + +/** + * Parses a proxy setting using the same rules for activation-time validation and request-time + * resolution. + * + * Only `http:` and `https:` proxies are supported. Values without a scheme (`localhost:3128`) + * are reported as `missing-protocol` rather than being silently accepted. + * + * @param value Raw proxy setting. + */ +export function parseProxyConfiguration(value: string): ProxyConfigurationParseResult { + const trimmed = value.trim(); + + if (!SCHEME_PATTERN.test(trimmed)) { + if (/^https?:/i.test(trimmed)) { + return { + ok: false, + issue: { + kind: "invalid-url", + error: new Error("HTTP proxy URL is malformed."), + }, + }; + } + + const scheme = trimmed.match(/^([A-Za-z][A-Za-z0-9+.-]*):/)?.[1]; + const looksLikeHostAndPort = /^[^:/\\]+:\d+(?:$|[/\\])/.test(trimmed); + if (scheme && !looksLikeHostAndPort) { + return { + ok: false, + issue: { + kind: "unsupported-protocol", + protocol: `${scheme.toLowerCase()}:`, + }, + }; + } + + return { ok: false, issue: { kind: "missing-protocol" } }; + } + + let url: URL; + try { + url = new URL(trimmed); + } catch (error) { + return { ok: false, issue: { kind: "invalid-url", error } }; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { ok: false, issue: { kind: "unsupported-protocol", protocol: url.protocol } }; + } + + if (!url.hostname) { + return { + ok: false, + issue: { kind: "invalid-url", error: new Error("Proxy URL has no host.") }, + }; + } + + return { ok: true, url }; +} + +/** + * Builds a credential-free, non-localized description of a proxy configuration problem. + * + * The raw proxy value is intentionally excluded because it may embed credentials. + * + * @param issue Problem reported by {@link parseProxyConfiguration}. + */ +export function describeProxyConfigurationIssue(issue: ProxyConfigurationIssue): string { + switch (issue.kind) { + case "missing-protocol": + return "Proxy setting is missing a protocol (for example, http:// or https://)."; + case "unsupported-protocol": + return `Proxy setting uses an unsupported protocol '${issue.protocol}'; only http: and https: are supported.`; + case "invalid-url": + return "Proxy setting could not be parsed as a URL."; + } +} + +/** + * Formats a proxy endpoint for diagnostics without exposing credentials. + * + * Username, password, query, and fragment are omitted. + * + * @param proxyUrl Parsed proxy endpoint. + */ +export function getRedactedProxyDescription(proxyUrl: URL): string { + return `${proxyUrl.protocol}//${proxyUrl.hostname}:${getProxyPort(proxyUrl)}`; +} + +/** + * Returns the effective port for a proxy endpoint, applying protocol defaults. + * + * @param proxyUrl Parsed proxy endpoint. + */ +export function getProxyPort(proxyUrl: URL): number { + if (proxyUrl.port) { + return Number(proxyUrl.port); + } + + return proxyUrl.protocol === "https:" ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT; +} + +/** + * Selects the environment proxy for a target URL. + * + * `HTTP` targets read `HTTP_PROXY` then `http_proxy`. `HTTPS` targets read `HTTPS_PROXY`, + * `https_proxy`, `HTTP_PROXY`, then `http_proxy`. `NO_PROXY`/`no_proxy` bypasses the proxy. + * + * @param target Absolute URL of the request. + * @param environment Environment variables to read; defaults to `process.env`. + */ +export function resolveEnvironmentProxyValue( + target: URL, + environment: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (target.protocol !== "http:" && target.protocol !== "https:") { + return undefined; + } + + if (!shouldProxyTarget(target, environment)) { + return undefined; + } + + const names = + target.protocol === "https:" + ? ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] + : ["HTTP_PROXY", "http_proxy"]; + + for (const name of names) { + const value = environment[name]; + if (value && value.trim()) { + return value.trim(); + } + } + + return undefined; +} + +/** + * Creates a proxy resolver backed by the standard proxy environment variables. + * + * @param options Optional overrides for the environment source and certificate validation. + */ +export function createEnvironmentProxyResolver(options?: { + /** Environment variables to read; defaults to `process.env`. */ + readonly environment?: NodeJS.ProcessEnv; + + /** Whether an `https:` proxy certificate must be valid. Defaults to `true`. */ + readonly rejectUnauthorized?: boolean; +}): IProxyResolver { + return { + resolve(target: URL): IProxyConfiguration | undefined { + const value = resolveEnvironmentProxyValue(target, options?.environment); + if (!value) { + return undefined; + } + + const parsed = parseProxyConfiguration(value); + if (parsed.ok === false) { + throw new Error(describeProxyConfigurationIssue(parsed.issue)); + } + + return { + url: parsed.url, + rejectUnauthorized: options?.rejectUnauthorized !== false, + source: "environment", + }; + }, + }; +} + +/** + * Resolves an explicit host proxy setting with an environment fallback. + * + * A non-empty explicit setting always takes precedence, including when the target matches + * `NO_PROXY`. When it is absent, the target-aware environment resolver is used. + * + * @param target Absolute request URL. + * @param explicitProxyValue Explicit host proxy setting, such as VS Code's `http.proxy`. + * @param rejectUnauthorized Whether an `https:` proxy certificate must be valid. + * @param environment Environment variables used by the fallback resolver. + */ +export function resolveProxyConfiguration( + target: URL, + explicitProxyValue: string | undefined, + rejectUnauthorized: boolean, + environment: NodeJS.ProcessEnv = process.env, +): IProxyConfiguration | undefined { + const explicitValue = explicitProxyValue?.trim(); + if (!explicitValue) { + return createEnvironmentProxyResolver({ environment, rejectUnauthorized }).resolve(target); + } + + const parsed = parseProxyConfiguration(explicitValue); + if (parsed.ok === false) { + throw new Error(describeProxyConfigurationIssue(parsed.issue)); + } + + return { + url: parsed.url, + rejectUnauthorized, + source: "vscode", + }; +} + +/** Connection options used to reach a proxy server. */ +export interface IProxyConnectionOptions { + /** Proxy host name. */ + readonly host: string; + + /** Proxy port. */ + readonly port: number; + + /** `user:password` credentials sent in the proxy authorization header, when configured. */ + readonly proxyAuth?: string; + + /** Whether the `https:` proxy certificate must be valid. Only set for `https:` proxies. */ + readonly rejectUnauthorized?: boolean; +} + +/** Options passed to a proxy agent factory. */ +export interface IProxyAgentOptions { + /** How to connect to the proxy server. */ + readonly proxy: IProxyConnectionOptions; +} + +/** + * Creates connection-pooling agents that route requests through a proxy. + * + * The four members cover each combination of request protocol and proxy protocol. + */ +export interface IProxyAgentFactory { + /** + * Creates an agent for an `http:` request through an `http:` proxy. + * + * @param options Proxy connection options. + */ + httpOverHttp(options: IProxyAgentOptions): http.Agent; + + /** + * Creates an agent for an `http:` request through an `https:` proxy. + * + * @param options Proxy connection options. + */ + httpOverHttps(options: IProxyAgentOptions): http.Agent; + + /** + * Creates an agent for an `https:` request through an `http:` proxy. + * + * @param options Proxy connection options. + */ + httpsOverHttp(options: IProxyAgentOptions): http.Agent; + + /** + * Creates an agent for an `https:` request through an `https:` proxy. + * + * @param options Proxy connection options. + */ + httpsOverHttps(options: IProxyAgentOptions): http.Agent; +} + +function shouldProxyTarget(target: URL, environment: NodeJS.ProcessEnv): boolean { + const noProxy = (environment.NO_PROXY || environment.no_proxy || "").trim().toLowerCase(); + if (!noProxy) { + return true; + } + + if (noProxy === "*") { + return false; + } + + const hostname = target.hostname.toLowerCase(); + const port = target.port + ? Number(target.port) + : target.protocol === "https:" + ? DEFAULT_HTTPS_PORT + : DEFAULT_HTTP_PORT; + + return noProxy.split(/[,\s]/).every((entry) => { + if (!entry) { + return true; + } + + const withPort = entry.match(/^(.+):(\d+)$/); + let entryHost = withPort ? withPort[1] : entry; + const entryPort = withPort ? Number(withPort[2]) : 0; + + if (entryPort && entryPort !== port) { + return true; + } + + if (!/^[.*]/.test(entryHost)) { + return hostname !== entryHost; + } + + if (entryHost.startsWith("*")) { + entryHost = entryHost.slice(1); + } + + return !hostname.endsWith(entryHost); + }); +} diff --git a/packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts b/packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts index 440ae403c8..0746a16c70 100644 --- a/packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts +++ b/packages/extension-toolkit/src/vscode/http/vscodeHttpClient.ts @@ -6,42 +6,80 @@ import * as vscode from "vscode"; import { HttpClient, - IHttpClientDependencies, IHttpClientLogger, - IHttpClientMessages, + IProxyConfiguration, + IProxyResolver, + parseProxyConfiguration, + ProxyConfigurationIssue, + resolveProxyConfiguration, } from "../../base"; /** Options for creating a VS Code-aware HTTP client. */ export interface IVscodeHttpClientOptions { - /** Localized messages used when reporting invalid VS Code proxy settings. */ - messages: IHttpClientMessages; - - /** Optional logger for HTTP diagnostics and proxy configuration warnings. */ - logger?: IHttpClientLogger; + /** Optional logger for credential-free HTTP diagnostics. */ + readonly logger?: IHttpClientLogger; } /** * An HTTP client configured from VS Code's `http.proxy` and `http.proxyStrictSSL` settings. - * Invalid proxy settings are reported through VS Code notifications and the optional logger. + * + * Proxy precedence is the `http.proxy` setting, then the standard proxy environment variables, + * then a direct connection. `http.proxyStrictSSL` controls certificate validation for `https:` + * proxy connections only; the destination server's certificate is always validated. + * + * Invalid proxy settings surface as a `proxy-configuration` error on the failing request. Use + * {@link getVscodeProxyConfigurationIssue} to report them during activation with a localized + * message. */ export class VscodeHttpClient extends HttpClient { /** * Creates a VS Code-aware HTTP client. * - * @param options Localized proxy messages and optional diagnostic logger. + * @param options Optional diagnostic logger. */ - constructor(options: IVscodeHttpClientOptions) { - const dependencies: IHttpClientDependencies = { - getProxyConfig: () => - vscode.workspace.getConfiguration("http")["proxy"] as string | undefined, - getProxyStrictSSL: () => - vscode.workspace.getConfiguration("http")["proxyStrictSSL"] as boolean | undefined, - parseUriScheme: (value: string) => vscode.Uri.parse(value).scheme, - showWarningMessage: (message: string) => { - void vscode.window.showWarningMessage(message); - }, - messages: options.messages, - }; - super(options.logger, dependencies); + constructor(options: IVscodeHttpClientOptions = {}) { + super({ + logger: options.logger, + proxyResolver: createVscodeProxyResolver(), + }); } } + +/** + * Creates a proxy resolver backed by VS Code's `http.proxy` and `http.proxyStrictSSL` settings + * with an environment variable fallback. + */ +export function createVscodeProxyResolver(): IProxyResolver { + return { + resolve(target: URL): IProxyConfiguration | undefined { + const rejectUnauthorized = getProxyStrictSSL() !== false; + return resolveProxyConfiguration(target, getVscodeProxySetting(), rejectUnauthorized); + }, + }; +} + +/** + * Validates VS Code's `http.proxy` setting using the same parser the client uses at request time. + * + * @returns The problem with the configured proxy, or `undefined` when the setting is empty or + * valid. Callers own presenting a localized warning; the raw setting is intentionally not + * returned because it may embed credentials. + */ +export function getVscodeProxyConfigurationIssue(): ProxyConfigurationIssue | undefined { + const configured = getVscodeProxySetting(); + if (!configured) { + return undefined; + } + + const parsed = parseProxyConfiguration(configured); + return parsed.ok === false ? parsed.issue : undefined; +} + +function getVscodeProxySetting(): string | undefined { + const value = vscode.workspace.getConfiguration("http").get("proxy"); + return value?.trim() ? value.trim() : undefined; +} + +function getProxyStrictSSL(): boolean | undefined { + return vscode.workspace.getConfiguration("http").get("proxyStrictSSL"); +} diff --git a/packages/extension-toolkit/test/base/http/download.test.ts b/packages/extension-toolkit/test/base/http/download.test.ts new file mode 100644 index 0000000000..6e71bfafac --- /dev/null +++ b/packages/extension-toolkit/test/base/http/download.test.ts @@ -0,0 +1,423 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from "fs"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import * as chai from "chai"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import sinonChai from "sinon-chai"; +import { HttpClient } from "../../../src/base/http/httpClient"; +import { HttpClientError } from "../../../src/base/http/httpErrors"; +import { IDownloadProgress } from "../../../src/base/http/httpTypes"; +import { IProxyResolver } from "../../../src/base/http/proxy"; +import { ITestServer, startTestServer } from "./testServer"; + +chai.use(sinonChai); + +const noProxy: IProxyResolver = { resolve: () => undefined }; +const payload = "downloaded-content"; + +function createClient(): HttpClient { + return new HttpClient({ proxyResolver: noProxy }); +} + +async function captureHttpClientError(action: () => Promise): Promise { + try { + await action(); + expect.fail("Expected the download to reject."); + } catch (error) { + expect(error).to.be.instanceOf(HttpClientError); + return error as HttpClientError; + } +} + +describe("HttpClient downloads", () => { + let server: ITestServer | undefined; + let workingDirectory: string; + let sandbox: sinon.SinonSandbox; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + workingDirectory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "toolkit-download-")); + }); + + afterEach(async () => { + sandbox.restore(); + await server?.close(); + server = undefined; + await fsPromises.rm(workingDirectory, { recursive: true, force: true }); + }); + + describe("downloadToPath", () => { + it("writes the response body to the destination", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": String(payload.length) }); + response.end(payload); + }); + const destination = path.join(workingDirectory, "package.zip"); + + const result = await createClient().downloadToPath(server.origin, destination); + + expect(result.ok).to.be.true; + expect(await fsPromises.readFile(destination, "utf8")).to.equal(payload); + }); + + it("leaves no staging files behind after a successful download", async () => { + server = await startTestServer((_request, response) => response.end(payload)); + const destination = path.join(workingDirectory, "package.zip"); + + await createClient().downloadToPath(server.origin, destination); + + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal(["package.zip"]); + }); + + it("does not create the destination for a non-success response", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(404); + response.end("missing"); + }); + const destination = path.join(workingDirectory, "package.zip"); + + const result = await createClient().downloadToPath(server.origin, destination); + + expect(result.ok).to.be.false; + expect(fs.existsSync(destination)).to.be.false; + }); + + it("preserves an existing destination when the response is a non-success", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(500); + response.end("boom"); + }); + const destination = path.join(workingDirectory, "package.zip"); + await fsPromises.writeFile(destination, "previous", "utf8"); + + await createClient().downloadToPath(server.origin, destination); + + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + }); + + it("preserves an existing destination when the response stream fails", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "1024" }); + response.write(payload); + response.destroy(); + }); + const destination = path.join(workingDirectory, "package.zip"); + await fsPromises.writeFile(destination, "previous", "utf8"); + + await captureHttpClientError(() => + createClient().downloadToPath(server!.origin, destination), + ); + + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + }); + + it("removes the staging file when the response stream fails", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "1024" }); + response.write(payload); + response.destroy(); + }); + const destination = path.join(workingDirectory, "package.zip"); + + await captureHttpClientError(() => + createClient().downloadToPath(server!.origin, destination), + ); + + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal([]); + }); + + it("fails when the body ends before the declared content length", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "1024" }); + response.write(payload); + // Delayed so the response headers are delivered before the stream is broken. + setTimeout(() => response.destroy(), 25); + }); + const destination = path.join(workingDirectory, "package.zip"); + + const error = await captureHttpClientError(() => + createClient().downloadToPath(server!.origin, destination), + ); + + expect(error.kind).to.equal("response-stream"); + }); + + it("reports cancellation while the body is streaming", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "1024" }); + response.write(payload); + }); + const destination = path.join(workingDirectory, "package.zip"); + await fsPromises.writeFile(destination, "previous", "utf8"); + const controller = new AbortController(); + const pending = createClient().downloadToPath(server.origin, destination, { + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 25); + + const error = await captureHttpClientError(() => pending); + + expect(error.kind).to.equal("cancelled"); + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal(["package.zip"]); + }); + + it("preserves an existing destination after a request failure", async () => { + const closedServer = await startTestServer((_request, response) => response.end()); + const origin = closedServer.origin; + await closedServer.close(); + const destination = path.join(workingDirectory, "package.zip"); + await fsPromises.writeFile(destination, "previous", "utf8"); + + const error = await captureHttpClientError(() => + createClient().downloadToPath(origin, destination), + ); + + expect(error.kind).to.equal("network"); + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal(["package.zip"]); + }); + + it("reports a timeout and preserves the destination while the body is streaming", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "1024" }); + response.write(payload); + }); + const destination = path.join(workingDirectory, "package.zip"); + await fsPromises.writeFile(destination, "previous", "utf8"); + + const error = await captureHttpClientError(() => + createClient().downloadToPath(server!.origin, destination, { timeoutMs: 50 }), + ); + + expect(error.kind).to.equal("timeout"); + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal(["package.zip"]); + }); + + it("preserves an existing destination after a destination write failure", async () => { + server = await startTestServer((_request, response) => response.end(payload)); + // The destination component is valid, but adding the staging suffix exceeds the + // portable 255-character component limit and makes the staging write fail. + const destination = path.join(workingDirectory, "a".repeat(240)); + await fsPromises.writeFile(destination, "previous", "utf8"); + + const error = await captureHttpClientError(() => + createClient().downloadToPath(server!.origin, destination), + ); + + expect(error.kind).to.equal("destination"); + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal(["a".repeat(240)]); + }); + + it("forwards custom request headers", async () => { + let receivedHeader: string | undefined; + server = await startTestServer((request, response) => { + receivedHeader = request.headers["x-download-id"] as string | undefined; + response.end(payload); + }); + + await createClient().downloadToPath( + server.origin, + path.join(workingDirectory, "package.zip"), + { headers: { "X-Download-Id": "abc123" } }, + ); + + expect(receivedHeader).to.equal("abc123"); + }); + }); + + describe("progress reporting", () => { + it("emits an initial zero-byte progress event with the total size", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": String(payload.length) }); + response.end(payload); + }); + const events: IDownloadProgress[] = []; + + await createClient().downloadToPath( + server.origin, + path.join(workingDirectory, "package.zip"), + { onProgress: (progress) => events.push(progress) }, + ); + + expect(events[0]).to.deep.equal({ + downloadedBytes: 0, + totalBytes: payload.length, + }); + }); + + it("reports the full size once the download completes", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": String(payload.length) }); + response.end(payload); + }); + const events: IDownloadProgress[] = []; + + await createClient().downloadToPath( + server.origin, + path.join(workingDirectory, "package.zip"), + { onProgress: (progress) => events.push(progress) }, + ); + + expect(events[events.length - 1].downloadedBytes).to.equal(payload.length); + }); + + it("reports an unknown total when no content length is sent", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "transfer-encoding": "chunked" }); + response.write(payload); + response.end(); + }); + const events: IDownloadProgress[] = []; + + await createClient().downloadToPath( + server.origin, + path.join(workingDirectory, "package.zip"), + { onProgress: (progress) => events.push(progress) }, + ); + + expect(events[0].totalBytes).to.be.undefined; + }); + + it("reports a known empty response as zero total bytes", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "0" }); + response.end(); + }); + const onProgress = sandbox.spy(); + + await createClient().downloadToPath( + server.origin, + path.join(workingDirectory, "package.zip"), + { onProgress }, + ); + + expect(onProgress).to.have.been.calledWith({ + downloadedBytes: 0, + totalBytes: 0, + }); + }); + + it("surfaces a throwing progress callback as a progress-callback failure", async () => { + server = await startTestServer((_request, response) => response.end(payload)); + + const error = await captureHttpClientError(() => + createClient().downloadToPath( + server!.origin, + path.join(workingDirectory, "package.zip"), + { + onProgress: () => { + throw new Error("callback failed"); + }, + }, + ), + ); + + expect(error.kind).to.equal("progress-callback"); + }); + + it("preserves an existing destination when a progress callback fails mid-stream", async () => { + server = await startTestServer((_request, response) => response.end(payload)); + const destination = path.join(workingDirectory, "package.zip"); + await fsPromises.writeFile(destination, "previous", "utf8"); + + const error = await captureHttpClientError(() => + createClient().downloadToPath(server!.origin, destination, { + onProgress: (progress) => { + if (progress.downloadedBytes > 0) { + throw new Error("callback failed"); + } + }, + }), + ); + + expect(error.kind).to.equal("progress-callback"); + expect(await fsPromises.readFile(destination, "utf8")).to.equal("previous"); + expect(await fsPromises.readdir(workingDirectory)).to.deep.equal(["package.zip"]); + }); + }); + + describe("downloadToFileDescriptor", () => { + it("writes the response body into the descriptor", async () => { + server = await startTestServer((_request, response) => response.end(payload)); + const destination = path.join(workingDirectory, "package.zip"); + const descriptor = fs.openSync(destination, "w"); + + try { + const result = await createClient().downloadToFileDescriptor( + server.origin, + descriptor, + ); + + expect(result.ok).to.be.true; + } finally { + fs.closeSync(descriptor); + } + + expect(await fsPromises.readFile(destination, "utf8")).to.equal(payload); + }); + + it("leaves the caller's descriptor open after a successful download", async () => { + server = await startTestServer((_request, response) => response.end(payload)); + const descriptor = fs.openSync(path.join(workingDirectory, "package.zip"), "w"); + + try { + await createClient().downloadToFileDescriptor(server.origin, descriptor); + + expect(() => fs.writeSync(descriptor, "still-open")).not.to.throw(); + } finally { + fs.closeSync(descriptor); + } + }); + + it("leaves the caller's descriptor open after a failed download", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-length": "1024" }); + response.write(payload); + response.destroy(); + }); + const descriptor = fs.openSync(path.join(workingDirectory, "package.zip"), "w"); + + try { + await captureHttpClientError(() => + createClient().downloadToFileDescriptor(server!.origin, descriptor), + ); + + expect(() => fs.writeSync(descriptor, "still-open")).not.to.throw(); + } finally { + fs.closeSync(descriptor); + } + }); + + it("does not write anything for a non-success response", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(404); + response.end("missing"); + }); + const destination = path.join(workingDirectory, "package.zip"); + const descriptor = fs.openSync(destination, "w"); + + try { + const result = await createClient().downloadToFileDescriptor( + server.origin, + descriptor, + ); + + expect(result.ok).to.be.false; + } finally { + fs.closeSync(descriptor); + } + + expect(await fsPromises.readFile(destination, "utf8")).to.equal(""); + }); + }); +}); diff --git a/packages/extension-toolkit/test/base/http/httpClient.test.ts b/packages/extension-toolkit/test/base/http/httpClient.test.ts new file mode 100644 index 0000000000..cb3f534e6e --- /dev/null +++ b/packages/extension-toolkit/test/base/http/httpClient.test.ts @@ -0,0 +1,507 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as http from "http"; +import axios, { AxiosHeaders, AxiosResponse } from "axios"; +import * as chai from "chai"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import sinonChai from "sinon-chai"; +import { HttpClient } from "../../../src/base/http/httpClient"; +import { HttpClientError } from "../../../src/base/http/httpErrors"; +import { IHttpClientLogger } from "../../../src/base/http/httpTypes"; +import { + IProxyAgentFactory, + IProxyAgentOptions, + IProxyResolver, +} from "../../../src/base/http/proxy"; +import { ITestServer, readRequestBody, startTestServer } from "./testServer"; + +chai.use(sinonChai); + +const noProxy: IProxyResolver = { resolve: () => undefined }; + +function createClient(overrides: { + logger?: IHttpClientLogger; + proxyResolver?: IProxyResolver; + proxyAgentFactory?: IProxyAgentFactory; +}): HttpClient { + return new HttpClient({ proxyResolver: noProxy, ...overrides }); +} + +async function captureHttpClientError(action: () => Promise): Promise { + try { + await action(); + expect.fail("Expected the HTTP operation to reject."); + } catch (error) { + expect(error).to.be.instanceOf(HttpClientError); + return error as HttpClientError; + } +} + +function createAxiosResponse(data: T): AxiosResponse { + return { + data, + status: 200, + statusText: "OK", + headers: {}, + config: { headers: new AxiosHeaders() }, + }; +} + +describe("HttpClient", () => { + let server: ITestServer | undefined; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(async () => { + sandbox.restore(); + await server?.close(); + server = undefined; + }); + + describe("responses", () => { + it("returns the parsed body and status for a successful response", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ value: "ok" })); + }); + + const result = await createClient({}).get<{ value: string }>(server.origin); + + expect(result.status).to.equal(200); + expect(result.ok).to.be.true; + expect(result.data).to.deep.equal({ value: "ok" }); + }); + + it("resolves rather than throwing for a non-success status", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "missing" })); + }); + + const result = await createClient({}).get(server.origin); + + expect(result.status).to.equal(404); + expect(result.ok).to.be.false; + }); + + it("treats 204 as a success", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(204); + response.end(); + }); + + expect((await createClient({}).get(server.origin)).ok).to.be.true; + }); + + it("treats 300 as a non-success", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(300); + response.end(); + }); + + expect((await createClient({}).get(server.origin)).ok).to.be.false; + }); + + it("exposes response headers case-insensitively", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "X-Trace-Id": "abc123" }); + response.end(); + }); + + const result = await createClient({}).get(server.origin); + + expect(result.headers.get("x-trace-id")).to.equal("abc123"); + expect(result.headers.get("X-TRACE-ID")).to.equal("abc123"); + }); + + it("preserves every value of a repeated response header", async () => { + server = await startTestServer((_request, response) => { + response.writeHead(200, { "Set-Cookie": ["a=1", "b=2"] }); + response.end(); + }); + + const result = await createClient({}).get(server.origin); + + expect(result.headers.getAll("set-cookie")).to.deep.equal(["a=1", "b=2"]); + }); + + it("constructs a toolkit response rather than returning the Axios object", async () => { + const axiosResponse = { + ...createAxiosResponse({ value: "ok" }), + request: { transport: "axios" }, + }; + sandbox.stub(axios, "request").resolves(axiosResponse); + + const result = await createClient({}).get<{ value: string }>( + "http://example.test/resource", + ); + + expect(result).to.deep.equal({ + data: { value: "ok" }, + status: 200, + statusText: "OK", + ok: true, + headers: result.headers, + }); + expect(result).not.to.have.property("config"); + expect(result).not.to.have.property("request"); + }); + }); + + describe("requests", () => { + it("sends caller-supplied request headers", async () => { + let received: http.IncomingHttpHeaders | undefined; + server = await startTestServer((request, response) => { + received = request.headers; + response.writeHead(200); + response.end(); + }); + + await createClient({}).get(server.origin, { + headers: { Authorization: "Bearer token" }, + }); + + expect(received?.authorization).to.equal("Bearer token"); + expect(received?.["content-type"]).to.be.undefined; + }); + + it("sends the direct request URL to Axios unchanged", async () => { + const request = sandbox.stub(axios, "request").resolves(createAxiosResponse({})); + const url = "http://example.test/resource?api-version=1"; + + await createClient({}).get(url); + + expect(request).to.have.been.calledOnceWith( + sinon.match({ + method: "GET", + url, + }), + ); + }); + + it("sends a JSON body with JSON content negotiation headers", async () => { + let received: { headers: http.IncomingHttpHeaders; body: string } | undefined; + server = await startTestServer(async (request, response) => { + received = { headers: request.headers, body: await readRequestBody(request) }; + response.writeHead(200); + response.end(); + }); + + await createClient({}).postJson(server.origin, { name: "value" }); + + expect(received!.headers["content-type"]).to.match(/^application\/json/); + expect(received!.headers.accept).to.equal("application/json"); + expect(received!.body).to.equal(JSON.stringify({ name: "value" })); + }); + + it("does not override a caller-supplied content type", async () => { + let received: http.IncomingHttpHeaders | undefined; + server = await startTestServer(async (request, response) => { + received = request.headers; + await readRequestBody(request); + response.writeHead(200); + response.end(); + }); + + await createClient({}).postJson( + server.origin, + { name: "value" }, + { headers: { "content-type": "application/merge-patch+json" } }, + ); + + expect(received?.["content-type"]).to.equal("application/merge-patch+json"); + }); + }); + + describe("error handling", () => { + it("reports an invalid URL as a network failure", async () => { + const error = await captureHttpClientError(() => createClient({}).get("not-a-url")); + + expect(error.kind).to.equal("network"); + expect(error.code).to.equal("ERR_INVALID_URL"); + expect(error.cause).to.be.instanceOf(Error); + }); + + it("reports a refused connection as a network failure", async () => { + const closed = await startTestServer((_request, response) => response.end()); + const origin = closed.origin; + await closed.close(); + + const error = await captureHttpClientError(() => createClient({}).get(origin)); + + expect(error.kind).to.equal("network"); + expect(error.code).to.equal("ECONNREFUSED"); + expect(error.cause).to.be.instanceOf(Error); + }); + + it("reports an elapsed timeout", async () => { + server = await startTestServer(() => { + // Intentionally never responds so the client timeout elapses. + }); + + const error = await captureHttpClientError(() => + createClient({}).get(server!.origin, { timeoutMs: 50 }), + ); + + expect(error.kind).to.equal("timeout"); + }); + + it("reports cancellation through an abort signal", async () => { + server = await startTestServer(() => { + // Intentionally never responds so the request is cancelled while in flight. + }); + + const controller = new AbortController(); + const pending = createClient({}).get(server.origin, { signal: controller.signal }); + setTimeout(() => controller.abort(), 25); + + const error = await captureHttpClientError(() => pending); + + expect(error.kind).to.equal("cancelled"); + }); + + it("reports an unusable proxy configuration", async () => { + server = await startTestServer((_request, response) => response.end()); + + const failingResolver: IProxyResolver = { + resolve: () => { + throw new Error("bad proxy"); + }, + }; + + const error = await captureHttpClientError(() => + createClient({ proxyResolver: failingResolver }).get(server!.origin), + ); + + expect(error.kind).to.equal("proxy-configuration"); + expect(error.cause).to.be.instanceOf(Error); + }); + + it("does not log credentials from a failing proxy resolver", async () => { + const logger = { + debug: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }; + const failingResolver: IProxyResolver = { + resolve: () => { + throw new Error("https://user:secret@proxy.example.com"); + }, + }; + + await captureHttpClientError(() => + createClient({ logger, proxyResolver: failingResolver }).get( + "https://example.test", + ), + ); + + expect(logger.error).to.have.been.calledOnce; + expect(logger.error.firstCall.args.join(" ")).not.to.contain("user"); + expect(logger.error.firstCall.args.join(" ")).not.to.contain("secret"); + }); + }); + + describe("proxy agents", () => { + function createRecordingFactory(): { + factory: IProxyAgentFactory; + calls: { method: string; options: IProxyAgentOptions }[]; + } { + const calls: { method: string; options: IProxyAgentOptions }[] = []; + const record = (method: string) => (options: IProxyAgentOptions) => { + calls.push({ method, options }); + return new http.Agent(); + }; + + return { + calls, + factory: { + httpOverHttp: sandbox.stub().callsFake(record("httpOverHttp")), + httpOverHttps: sandbox.stub().callsFake(record("httpOverHttps")), + httpsOverHttp: sandbox.stub().callsFake(record("httpsOverHttp")), + httpsOverHttps: sandbox.stub().callsFake(record("httpsOverHttps")), + }, + }; + } + + it("does not create an agent when no proxy is resolved", async () => { + server = await startTestServer((_request, response) => response.end()); + const { factory, calls } = createRecordingFactory(); + + await createClient({ proxyAgentFactory: factory }).get(server.origin); + + expect(calls).to.be.empty; + }); + + it("uses an http-over-http agent for an http target behind an http proxy", async () => { + server = await startTestServer((_request, response) => response.end()); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("http://proxy.example.com:3128"), + rejectUnauthorized: true, + source: "environment", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get(server.origin); + + expect(calls).to.have.length(1); + expect(calls[0].method).to.equal("httpOverHttp"); + expect(calls[0].options.proxy.host).to.equal("proxy.example.com"); + expect(calls[0].options.proxy.port).to.equal(3128); + }); + + for (const testCase of [ + { + target: "http://example.test", + proxy: "https://proxy.example.com:3128", + expectedFactory: "httpOverHttps", + }, + { + target: "https://example.test", + proxy: "http://proxy.example.com:3128", + expectedFactory: "httpsOverHttp", + }, + { + target: "https://example.test", + proxy: "https://proxy.example.com:3128", + expectedFactory: "httpsOverHttps", + }, + ]) { + it(`uses ${testCase.expectedFactory} for ${new URL(testCase.target).protocol} over ${new URL(testCase.proxy).protocol}`, async () => { + sandbox.stub(axios, "request").resolves(createAxiosResponse({})); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL(testCase.proxy), + rejectUnauthorized: true, + source: "environment", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get( + testCase.target, + ); + + expect(calls).to.have.length(1); + expect(calls[0].method).to.equal(testCase.expectedFactory); + }); + } + + it("omits certificate options for an http proxy", async () => { + server = await startTestServer((_request, response) => response.end()); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("http://proxy.example.com:3128"), + rejectUnauthorized: false, + source: "vscode", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get(server.origin); + + expect(calls[0].options.proxy.rejectUnauthorized).to.be.undefined; + }); + + it("forwards certificate validation options for an https proxy", async () => { + server = await startTestServer((_request, response) => response.end()); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("https://proxy.example.com"), + rejectUnauthorized: false, + source: "vscode", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get(server.origin); + + expect(calls[0].method).to.equal("httpOverHttps"); + expect(calls[0].options.proxy.port).to.equal(443); + expect(calls[0].options.proxy.rejectUnauthorized).to.be.false; + }); + + it("forwards enabled certificate validation for an https proxy", async () => { + sandbox.stub(axios, "request").resolves(createAxiosResponse({})); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("https://proxy.example.com"), + rejectUnauthorized: true, + source: "vscode", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get( + "https://example.test", + ); + + expect(calls[0].method).to.equal("httpsOverHttps"); + expect(calls[0].options.proxy.rejectUnauthorized).to.be.true; + }); + + it("forwards proxy credentials", async () => { + server = await startTestServer((_request, response) => response.end()); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("http://user:secret@proxy.example.com:3128"), + rejectUnauthorized: true, + source: "environment", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get(server.origin); + + expect(calls[0].options.proxy.proxyAuth).to.equal("user:secret"); + }); + + it("decodes percent-encoded proxy credentials before authentication", async () => { + sandbox.stub(axios, "request").resolves(createAxiosResponse({})); + const { factory, calls } = createRecordingFactory(); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("http://us%40er:p%3Ass@proxy.example.com:3128"), + rejectUnauthorized: true, + source: "environment", + }), + }; + + await createClient({ proxyResolver, proxyAgentFactory: factory }).get( + "http://example.test", + ); + + expect(calls[0].options.proxy.proxyAuth).to.equal("us@er:p:ss"); + }); + + it("maps proxy agent construction failures to proxy-configuration", async () => { + const factory = createRecordingFactory().factory; + (factory.httpOverHttp as sinon.SinonStub).throws(new Error("factory failed")); + const proxyResolver: IProxyResolver = { + resolve: () => ({ + url: new URL("http://proxy.example.com:3128"), + rejectUnauthorized: true, + source: "environment", + }), + }; + + const error = await captureHttpClientError(() => + createClient({ proxyResolver, proxyAgentFactory: factory }).get( + "http://example.test", + ), + ); + + expect(error.kind).to.equal("proxy-configuration"); + expect(error.message).not.to.contain("factory failed"); + expect(error.cause).to.be.instanceOf(Error); + }); + }); +}); diff --git a/packages/extension-toolkit/test/base/http/httpHeaders.test.ts b/packages/extension-toolkit/test/base/http/httpHeaders.test.ts new file mode 100644 index 0000000000..b2fc0d292a --- /dev/null +++ b/packages/extension-toolkit/test/base/http/httpHeaders.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import { createHttpHeaders } from "../../../src/base/http/httpHeaders"; + +describe("HttpHeaders", () => { + describe("lookup", () => { + it("resolves names case-insensitively", () => { + const headers = createHttpHeaders({ "Content-Type": "application/json" }); + + expect(headers.get("content-type")).to.equal("application/json"); + expect(headers.get("CONTENT-TYPE")).to.equal("application/json"); + expect(headers.has("Content-Type")).to.be.true; + }); + + it("returns undefined for absent headers", () => { + const headers = createHttpHeaders({ Accept: "application/json" }); + + expect(headers.get("location")).to.be.undefined; + expect(headers.getAll("location")).to.deep.equal([]); + expect(headers.has("location")).to.be.false; + }); + }); + + describe("multi-value headers", () => { + it("keeps every value from an array initializer", () => { + const headers = createHttpHeaders({ "set-cookie": ["a=1", "b=2"] }); + + expect(headers.getAll("set-cookie")).to.deep.equal(["a=1", "b=2"]); + }); + + it("returns the first value from get", () => { + const headers = createHttpHeaders({ "set-cookie": ["a=1", "b=2"] }); + + expect(headers.get("set-cookie")).to.equal("a=1"); + }); + + it("accumulates repeated names from an entry-pair initializer", () => { + const headers = createHttpHeaders([ + ["Set-Cookie", "a=1"], + ["set-cookie", "b=2"], + ]); + + expect(headers.getAll("Set-Cookie")).to.deep.equal(["a=1", "b=2"]); + }); + + it("does not expose its mutable internal value arrays", () => { + const headers = createHttpHeaders({ "set-cookie": ["a=1", "b=2"] }); + const values = headers.getAll("set-cookie") as string[]; + + values.push("c=3"); + + expect(headers.getAll("set-cookie")).to.deep.equal(["a=1", "b=2"]); + }); + }); + + describe("normalization", () => { + it("omits headers without a usable value", () => { + const headers = createHttpHeaders({ + present: "yes", + missing: undefined, + empty: null, + structured: { nested: true }, + }); + + expect(headers.has("present")).to.be.true; + expect(headers.has("missing")).to.be.false; + expect(headers.has("empty")).to.be.false; + expect(headers.has("structured")).to.be.false; + }); + + it("stringifies primitive values", () => { + const headers = createHttpHeaders({ "content-length": 42, secure: true }); + + expect(headers.get("content-length")).to.equal("42"); + expect(headers.get("secure")).to.equal("true"); + }); + }); + + describe("entries", () => { + it("preserves the originally supplied casing", () => { + const headers = createHttpHeaders({ "Content-Type": "text/plain" }); + + expect([...headers.entries()]).to.deep.equal([["Content-Type", ["text/plain"]]]); + }); + + it("groups every value under a single entry", () => { + const headers = createHttpHeaders({ "set-cookie": ["a=1", "b=2"] }); + + expect([...headers.entries()]).to.deep.equal([["set-cookie", ["a=1", "b=2"]]]); + }); + }); +}); diff --git a/packages/extension-toolkit/test/base/http/proxy.test.ts b/packages/extension-toolkit/test/base/http/proxy.test.ts new file mode 100644 index 0000000000..99e46549ef --- /dev/null +++ b/packages/extension-toolkit/test/base/http/proxy.test.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from "chai"; +import { + createEnvironmentProxyResolver, + getProxyPort, + getRedactedProxyDescription, + parseProxyConfiguration, + resolveEnvironmentProxyValue, + resolveProxyConfiguration, +} from "../../../src/base/http/proxy"; + +describe("parseProxyConfiguration", () => { + it("accepts an http proxy", () => { + const result = parseProxyConfiguration("http://proxy.example.com:3128"); + + expect(result.ok).to.be.true; + expect(result.ok === true && result.url.hostname).to.equal("proxy.example.com"); + }); + + it("accepts an https proxy", () => { + const result = parseProxyConfiguration("https://proxy.example.com"); + + expect(result.ok).to.be.true; + expect(result.ok === true && result.url.protocol).to.equal("https:"); + }); + + it("trims surrounding whitespace", () => { + const result = parseProxyConfiguration(" http://proxy.example.com:3128 "); + + expect(result.ok).to.be.true; + }); + + it("reports a missing protocol", () => { + const result = parseProxyConfiguration("proxy.example.com:3128"); + + expect(result.ok).to.be.false; + expect(result.ok === false && result.issue.kind).to.equal("missing-protocol"); + }); + + it("reports an unsupported protocol", () => { + const result = parseProxyConfiguration("socks5://proxy.example.com:1080"); + + expect(result.ok).to.be.false; + expect(result.ok === false && result.issue.kind).to.equal("unsupported-protocol"); + }); + + it("reports an unsupported protocol even when its separator is malformed", () => { + const result = parseProxyConfiguration("socks5:proxy.example.com:1080"); + + expect(result.ok).to.be.false; + expect(result.ok === false && result.issue.kind).to.equal("unsupported-protocol"); + }); + + for (const value of ["http:/proxy.example.com", "https:proxy.example.com"]) { + it(`reports the malformed HTTP proxy '${value}' as invalid`, () => { + const result = parseProxyConfiguration(value); + + expect(result.ok).to.be.false; + expect(result.ok === false && result.issue.kind).to.equal("invalid-url"); + }); + } + + it("reports localhost without a protocol as missing a protocol", () => { + const result = parseProxyConfiguration("localhost:3128"); + + expect(result.ok).to.be.false; + expect(result.ok === false && result.issue.kind).to.equal("missing-protocol"); + }); + + it("reports a URL without a host", () => { + const result = parseProxyConfiguration("http://"); + + expect(result.ok).to.be.false; + expect(result.ok === false && result.issue.kind).to.equal("invalid-url"); + }); +}); + +describe("getProxyPort", () => { + it("uses the explicit port", () => { + expect(getProxyPort(new URL("http://proxy.example.com:3128"))).to.equal(3128); + }); + + it("defaults http proxies to 80", () => { + expect(getProxyPort(new URL("http://proxy.example.com"))).to.equal(80); + }); + + it("defaults https proxies to 443", () => { + expect(getProxyPort(new URL("https://proxy.example.com"))).to.equal(443); + }); +}); + +describe("getRedactedProxyDescription", () => { + it("omits credentials", () => { + const description = getRedactedProxyDescription( + new URL("http://user:secret@proxy.example.com:3128/path?token=abc"), + ); + + expect(description).to.equal("http://proxy.example.com:3128"); + }); +}); + +describe("resolveEnvironmentProxyValue", () => { + it("uses HTTP_PROXY for http targets", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + }); + + expect(value).to.equal("http://proxy.example.com:3128"); + }); + + it("prefers HTTPS_PROXY for https targets", () => { + const value = resolveEnvironmentProxyValue(new URL("https://example.com"), { + HTTPS_PROXY: "http://secure-proxy.example.com:3128", + HTTP_PROXY: "http://proxy.example.com:3128", + }); + + expect(value).to.equal("http://secure-proxy.example.com:3128"); + }); + + it("falls back to HTTP_PROXY for https targets", () => { + const value = resolveEnvironmentProxyValue(new URL("https://example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + }); + + expect(value).to.equal("http://proxy.example.com:3128"); + }); + + it("ignores HTTPS_PROXY for http targets", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com"), { + HTTPS_PROXY: "http://secure-proxy.example.com:3128", + }); + + expect(value).to.be.undefined; + }); + + it("ignores blank values", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com"), { + HTTP_PROXY: " ", + }); + + expect(value).to.be.undefined; + }); + + it("honors an exact NO_PROXY host", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + NO_PROXY: "example.com", + }); + + expect(value).to.be.undefined; + }); + + it("honors a NO_PROXY suffix match", () => { + const value = resolveEnvironmentProxyValue(new URL("http://api.example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + NO_PROXY: ".example.com", + }); + + expect(value).to.be.undefined; + }); + + it("honors a NO_PROXY wildcard", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + NO_PROXY: "*", + }); + + expect(value).to.be.undefined; + }); + + it("does not bypass unrelated NO_PROXY hosts", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + NO_PROXY: "other.com", + }); + + expect(value).to.equal("http://proxy.example.com:3128"); + }); + + it("respects a NO_PROXY entry with a non-matching port", () => { + const value = resolveEnvironmentProxyValue(new URL("http://example.com:8080"), { + HTTP_PROXY: "http://proxy.example.com:3128", + NO_PROXY: "example.com:9090", + }); + + expect(value).to.equal("http://proxy.example.com:3128"); + }); + + it("returns undefined for non-http protocols", () => { + const value = resolveEnvironmentProxyValue(new URL("ftp://example.com"), { + HTTP_PROXY: "http://proxy.example.com:3128", + }); + + expect(value).to.be.undefined; + }); +}); + +describe("createEnvironmentProxyResolver", () => { + it("returns undefined when no proxy is configured", () => { + const resolver = createEnvironmentProxyResolver({ environment: {} }); + + expect(resolver.resolve(new URL("https://example.com"))).to.be.undefined; + }); + + it("marks the resolved proxy as coming from the environment", () => { + const resolver = createEnvironmentProxyResolver({ + environment: { HTTPS_PROXY: "http://proxy.example.com:3128" }, + }); + + const proxy = resolver.resolve(new URL("https://example.com")); + + expect(proxy?.source).to.equal("environment"); + expect(proxy?.rejectUnauthorized).to.be.true; + }); + + it("propagates the requested certificate validation mode", () => { + const resolver = createEnvironmentProxyResolver({ + environment: { HTTPS_PROXY: "https://proxy.example.com:3128" }, + rejectUnauthorized: false, + }); + + expect(resolver.resolve(new URL("https://example.com"))?.rejectUnauthorized).to.be.false; + }); + + it("throws for an unusable proxy value", () => { + const resolver = createEnvironmentProxyResolver({ + environment: { HTTPS_PROXY: "proxy.example.com:3128" }, + }); + + expect(() => resolver.resolve(new URL("https://example.com"))).to.throw( + /missing a protocol/, + ); + }); +}); + +describe("resolveProxyConfiguration", () => { + it("prefers an explicit VS Code proxy over the environment", () => { + const proxy = resolveProxyConfiguration( + new URL("https://example.com"), + "https://vscode-proxy.example.com:444", + false, + { HTTPS_PROXY: "http://environment-proxy.example.com:3128" }, + ); + + expect(proxy?.source).to.equal("vscode"); + expect(proxy?.url.toString()).to.equal("https://vscode-proxy.example.com:444/"); + expect(proxy?.rejectUnauthorized).to.be.false; + }); + + it("falls back to the target-aware environment proxy", () => { + const proxy = resolveProxyConfiguration(new URL("https://example.com"), undefined, true, { + HTTPS_PROXY: "http://environment-proxy.example.com:3128", + }); + + expect(proxy?.source).to.equal("environment"); + expect(proxy?.url.toString()).to.equal("http://environment-proxy.example.com:3128/"); + expect(proxy?.rejectUnauthorized).to.be.true; + }); + + it("uses a direct connection when neither source configures a proxy", () => { + const proxy = resolveProxyConfiguration( + new URL("https://example.com"), + undefined, + true, + {}, + ); + + expect(proxy).to.be.undefined; + }); +}); diff --git a/packages/extension-toolkit/test/base/http/testServer.ts b/packages/extension-toolkit/test/base/http/testServer.ts new file mode 100644 index 0000000000..e87f62c973 --- /dev/null +++ b/packages/extension-toolkit/test/base/http/testServer.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as http from "http"; +import { AddressInfo } from "net"; + +/** A local HTTP server used to exercise the client against a real socket. */ +export interface ITestServer { + /** Origin of the running server, for example `http://127.0.0.1:51234`. */ + readonly origin: string; + + /** Stops the server and waits for it to close. */ + close(): Promise; +} + +/** + * Starts a local HTTP server bound to a loopback ephemeral port. + * + * @param handler Request handler used for every request. + */ +export async function startTestServer(handler: http.RequestListener): Promise { + const server = http.createServer(handler); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address() as AddressInfo; + + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +/** Reads an entire request body as a UTF-8 string. */ +export async function readRequestBody(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/extension-toolkit/tsconfig.test.json b/packages/extension-toolkit/tsconfig.test.json new file mode 100644 index 0000000000..bd668b3a8c --- /dev/null +++ b/packages/extension-toolkit/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "../../extensions/tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "out", + "noEmit": false, + "types": ["node", "mocha"] + }, + "include": ["src/base/**/*.ts", "test/**/*.ts"], + "exclude": ["dist", "out", "node_modules"] +} diff --git a/scripts/workspace-targets.mjs b/scripts/workspace-targets.mjs index 4de57a36d5..521cf7c269 100644 --- a/scripts/workspace-targets.mjs +++ b/scripts/workspace-targets.mjs @@ -5,7 +5,7 @@ export const workspaceTargets = [ aliases: ["extension-toolkit", "toolkit"], packageName: "extension-toolkit", directory: "packages/extension-toolkit", - scripts: ["build", "watch", "lint"], + scripts: ["build", "watch", "lint", "test"], }, { target: "mssql",