diff --git a/.vscodeignore b/.vscodeignore index cd7c06718..3981f02dc 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -12,4 +12,5 @@ !assets/walkthrough/** !assets/documentation-webview/** !assets/swift-docc-render/** +!assets/swift_askpass.sh !node_modules/@vscode/codicons/** diff --git a/assets/swift_askpass.sh b/assets/swift_askpass.sh new file mode 100755 index 000000000..31b86be02 --- /dev/null +++ b/assets/swift_askpass.sh @@ -0,0 +1,10 @@ +#!/bin/sh +VSCODE_SWIFT_ASKPASS_FILE=$(mktemp) + +ELECTRON_RUN_AS_NODE="1" VSCODE_SWIFT_ASKPASS_FILE="$VSCODE_SWIFT_ASKPASS_FILE" "$VSCODE_SWIFT_ASKPASS_NODE" "$VSCODE_SWIFT_ASKPASS_MAIN" +EXIT_CODE=$? + +cat "$VSCODE_SWIFT_ASKPASS_FILE" +rm "$VSCODE_SWIFT_ASKPASS_FILE" + +exit "$EXIT_CODE" diff --git a/package.json b/package.json index 00ddefe00..974f07aa8 100644 --- a/package.json +++ b/package.json @@ -1991,7 +1991,7 @@ "scripts": { "vscode:prepublish": "npm run bundle", "bundle": "del-cli ./dist && npm run bundle-extension && npm run bundle-documentation-webview", - "bundle-extension": "del-cli ./dist && esbuild ./src/extension.ts --bundle --outfile=dist/src/extension.js --external:vscode --define:process.env.NODE_ENV=\\\"production\\\" --define:process.env.CI=\\\"\\\" --format=cjs --platform=node --target=node18 --minify --sourcemap", + "bundle-extension": "del-cli ./dist && esbuild ./src/extension.ts ./src/askpass/askpass-main.ts --bundle --outdir=dist/src/ --external:vscode --define:process.env.NODE_ENV=\\\"production\\\" --define:process.env.CI=\\\"\\\" --format=cjs --platform=node --target=node18 --minify --sourcemap", "bundle-documentation-webview": "npm run compile-documentation-webview -- --minify", "compile": "del-cli ./dist/ && tsc --build", "watch": "npm run compile -- --watch", diff --git a/src/FolderContext.ts b/src/FolderContext.ts index 64360db80..7ed08a2ea 100644 --- a/src/FolderContext.ts +++ b/src/FolderContext.ts @@ -94,7 +94,10 @@ export class FolderContext implements vscode.Disposable { let toolchain: SwiftToolchain; try { - toolchain = await SwiftToolchain.create(folder); + toolchain = await SwiftToolchain.create( + workspaceContext.extensionContext.extensionPath, + folder + ); } catch (error) { // This error case is quite hard for the user to get in to, but possible. // Typically on startup the toolchain creation failure is going to happen in @@ -108,7 +111,10 @@ export class FolderContext implements vscode.Disposable { if (userMadeSelection) { // User updated toolchain settings, retry once try { - toolchain = await SwiftToolchain.create(folder); + toolchain = await SwiftToolchain.create( + workspaceContext.extensionContext.extensionPath, + folder + ); workspaceContext.logger.info( `Successfully created toolchain for ${FolderContext.uriName(folder)} after user selection`, FolderContext.uriName(folder) diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 56411ff40..d73c2c645 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -82,7 +82,7 @@ export class WorkspaceContext implements vscode.Disposable { public loggerFactory: SwiftLoggerFactory; constructor( - extensionContext: vscode.ExtensionContext, + public extensionContext: vscode.ExtensionContext, public contextKeys: ContextKeys, public logger: SwiftLogger, public globalToolchain: SwiftToolchain diff --git a/src/askpass/askpass-main.ts b/src/askpass/askpass-main.ts new file mode 100644 index 000000000..cd86c9fcb --- /dev/null +++ b/src/askpass/askpass-main.ts @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +/* eslint-disable no-console */ +import * as fs from "fs"; +import * as http from "http"; +import { z } from "zod/v4/mini"; + +const outputFile = process.env.VSCODE_SWIFT_ASKPASS_FILE; +if (!outputFile) { + throw new Error("Missing environment variable $VSCODE_SWIFT_ASKPASS_FILE"); +} + +const nonce = process.env.VSCODE_SWIFT_ASKPASS_NONCE; +if (!nonce) { + throw new Error("Missing environment variable $VSCODE_SWIFT_ASKPASS_NONCE"); +} + +const port = Number.parseInt(process.env.VSCODE_SWIFT_ASKPASS_PORT ?? "-1", 10); +if (isNaN(port) || port < 0) { + throw new Error("Missing environment variable $VSCODE_SWIFT_ASKPASS_PORT"); +} + +const req = http.request( + { + hostname: "localhost", + port: port, + path: `/askpass?nonce=${encodeURIComponent(nonce)}`, + method: "GET", + }, + res => { + function parseResponse(rawData: string): { password?: string } { + try { + const rawJSON = JSON.parse(rawData); + return z.object({ password: z.optional(z.string()) }).parse(rawJSON); + } catch { + // DO NOT log the underlying error here. It contains sensitive password info! + throw Error("Failed to parse response from askpass server."); + } + } + + let rawData = ""; + res.on("data", chunk => { + rawData += chunk; + }); + + res.on("end", () => { + if (res.statusCode !== 200) { + console.error(`Server responded with status code ${res.statusCode}`); + process.exit(1); + } + const password = parseResponse(rawData).password; + if (!password) { + console.error("User cancelled password input."); + process.exit(1); + } + try { + fs.writeFileSync(outputFile, password, "utf8"); + } catch (error) { + console.error(Error(`Unable to write to file ${outputFile}`, { cause: error })); + process.exit(1); + } + }); + } +); + +req.on("error", error => { + console.error(Error(`Request failed: GET ${req.host}/${req.path}`, { cause: error })); + process.exit(1); +}); + +req.end(); diff --git a/src/askpass/askpass-server.ts b/src/askpass/askpass-server.ts new file mode 100644 index 000000000..0402904e8 --- /dev/null +++ b/src/askpass/askpass-server.ts @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import * as crypto from "crypto"; +import * as http from "http"; +import * as vscode from "vscode"; + +/** Options that can be used to configure the behavior of {@link withAskpassServer}. */ +export interface WithAskpassServerOptions { + /** The title of the input box shown in VS Code. */ + title?: string; +} + +/** + * Creates a temporary HTTP server that can be used to handle askpass requests from various terminal + * applications. The server will be closed when the provided task completes. + * + * The task will be provided with a randomly generated nonce and port number used for connecting to + * the server. Requests without a valid nonce will be rejected with a 401 status code. + * + * @param task Function to execute while the server is listening for connections + * @returns Promise that resolves when the task completes and server is cleaned up + */ +export async function withAskpassServer( + task: (nonce: string, port: number) => Promise, + options: WithAskpassServerOptions = {} +): Promise { + const nonce = crypto.randomBytes(32).toString("hex"); + const server = http.createServer((req, res) => { + if (!req.url) { + return res.writeHead(404).end(); + } + + const url = new URL(req.url, `http://localhost`); + if (url.pathname !== "/askpass") { + return res.writeHead(404).end(); + } + + const requestNonce = url.searchParams.get("nonce"); + if (requestNonce !== nonce) { + return res.writeHead(401).end(); + } + + void vscode.window + .showInputBox({ + password: true, + title: options.title, + placeHolder: "Please enter your password", + ignoreFocusOut: true, + }) + .then(password => { + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ password }) + ); + }); + }); + + return new Promise((resolve, reject) => { + server.listen(0, "localhost", async () => { + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to get server port"); + } + const port = address.port; + resolve(await task(nonce, port)); + } catch (error) { + reject(error); + } finally { + server.close(); + } + }); + + server.on("error", error => { + reject(error); + }); + }); +} diff --git a/src/commands/installSwiftlyToolchain.ts b/src/commands/installSwiftlyToolchain.ts index 87853470d..6af34275b 100644 --- a/src/commands/installSwiftlyToolchain.ts +++ b/src/commands/installSwiftlyToolchain.ts @@ -32,6 +32,7 @@ import { */ export async function installSwiftlyToolchainWithProgress( version: string, + extensionRoot: string, logger?: SwiftLogger ): Promise { try { @@ -48,6 +49,7 @@ export async function installSwiftlyToolchainWithProgress( await Swiftly.installToolchain( version, + extensionRoot, (progressData: SwiftlyProgressData) => { if (progressData.complete) { // Swiftly will also verify the signature and extract the toolchain after the @@ -185,6 +187,7 @@ export async function promptToInstallSwiftlyToolchain( if ( !(await installSwiftlyToolchainWithProgress( selectedToolchain.toolchain.version.name, + ctx.extensionContext.extensionPath, ctx.logger )) ) { diff --git a/src/extension.ts b/src/extension.ts index f369d22ff..25b12ed84 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,7 +68,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { checkAndWarnAboutWindowsSymlinks(logger); const contextKeys = createContextKeys(); - const toolchain = await createActiveToolchain(contextKeys, logger); + const toolchain = await createActiveToolchain(context, contextKeys, logger); checkForSwiftlyInstallation(contextKeys, logger); // If we don't have a toolchain, show an error and stop initializing the extension. @@ -248,11 +248,12 @@ function handleFolderEvent(logger: SwiftLogger): (event: FolderEvent) => Promise } async function createActiveToolchain( + extension: vscode.ExtensionContext, contextKeys: ContextKeys, logger: SwiftLogger ): Promise { try { - const toolchain = await SwiftToolchain.create(undefined, logger); + const toolchain = await SwiftToolchain.create(extension.extensionPath, undefined, logger); toolchain.logDiagnostics(logger); contextKeys.updateKeysBasedOnActiveVersion(toolchain.swiftVersion); return toolchain; diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index 7e58c1a31..d6dd9aa29 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -21,6 +21,7 @@ import * as Stream from "stream"; import * as vscode from "vscode"; import { z } from "zod/v4/mini"; +import { withAskpassServer } from "../askpass/askpass-server"; import { installSwiftlyToolchainWithProgress } from "../commands/installSwiftlyToolchain"; import { ContextKeys } from "../contextKeys"; import { SwiftLogger } from "../logging/SwiftLogger"; @@ -160,6 +161,7 @@ export function parseSwiftlyMissingToolchainError( */ export async function handleMissingSwiftlyToolchain( version: string, + extensionRoot: string, logger?: SwiftLogger, folder?: vscode.Uri ): Promise { @@ -174,7 +176,7 @@ export async function handleMissingSwiftlyToolchain( // Use the existing installation function without showing reload notification // (since we want to continue the current operation) - return await installSwiftlyToolchainWithProgress(version, logger); + return await installSwiftlyToolchainWithProgress(version, extensionRoot, logger); } export class Swiftly { @@ -363,6 +365,7 @@ export class Swiftly { * @returns The location of the active toolchain if swiftly is being used to manage it. */ public static async toolchain( + extensionRoot: string, logger?: SwiftLogger, cwd?: vscode.Uri ): Promise { @@ -388,6 +391,7 @@ export class Swiftly { // Attempt automatic installation const installed = await handleMissingSwiftlyToolchain( missingToolchainError.version, + extensionRoot, logger, cwd ); @@ -478,6 +482,7 @@ export class Swiftly { */ public static async installToolchain( version: string, + extensionRoot: string, progressCallback?: (progressData: SwiftlyProgressData) => void, logger?: SwiftLogger, token?: vscode.CancellationToken @@ -587,7 +592,12 @@ export class Swiftly { } if (process.platform === "linux") { - await this.handlePostInstallFile(postInstallFilePath, version, logger); + await this.handlePostInstallFile( + postInstallFilePath, + version, + extensionRoot, + logger + ); } } catch (error) { if ( @@ -630,6 +640,7 @@ export class Swiftly { private static async handlePostInstallFile( postInstallFilePath: string, version: string, + extensionRoot: string, logger?: SwiftLogger ): Promise { try { @@ -655,7 +666,12 @@ export class Swiftly { const shouldExecute = await this.showPostInstallConfirmation(version, validation, logger); if (shouldExecute) { - await this.executePostInstallScript(postInstallFilePath, version, logger); + await this.executePostInstallScript( + postInstallFilePath, + version, + extensionRoot, + logger + ); } else { logger?.warn(`Swift ${version} post-install script execution cancelled by user`); void vscode.window.showWarningMessage( @@ -776,6 +792,7 @@ export class Swiftly { private static async executePostInstallScript( postInstallFilePath: string, version: string, + extensionRoot: string, logger?: SwiftLogger ): Promise { logger?.info(`Executing post-install script for toolchain ${version}`); @@ -786,11 +803,16 @@ export class Swiftly { outputChannel.show(true); outputChannel.appendLine(`Executing post-install script for Swift ${version}...`); outputChannel.appendLine(`Script location: ${postInstallFilePath}`); + outputChannel.appendLine("Script contents:"); + const scriptContents = await fs.readFile(postInstallFilePath, "utf-8"); + for (const line of scriptContents.split(/\r?\n/)) { + outputChannel.appendLine(" " + line); + } outputChannel.appendLine(""); await execFile("chmod", ["+x", postInstallFilePath]); - const command = "pkexec"; + const command = "sudo"; const args = [postInstallFilePath]; outputChannel.appendLine(`Executing: ${command} ${args.join(" ")}`); @@ -804,7 +826,31 @@ export class Swiftly { }, }); - await execFileStreamOutput(command, args, outputStream, outputStream, null, {}); + await withAskpassServer( + async (nonce, port) => { + await execFileStreamOutput( + command, + ["-A", ...args], + outputStream, + outputStream, + null, + { + env: { + ...process.env, + SUDO_ASKPASS: path.join(extensionRoot, "assets/swift_askpass.sh"), + VSCODE_SWIFT_ASKPASS_NODE: process.execPath, + VSCODE_SWIFT_ASKPASS_MAIN: path.join( + extensionRoot, + "dist/src/askpass/askpass-main.js" + ), + VSCODE_SWIFT_ASKPASS_NONCE: nonce, + VSCODE_SWIFT_ASKPASS_PORT: port.toString(10), + }, + } + ); + }, + { title: "sudo password for Swiftly post-install script" } + ); outputChannel.appendLine(""); outputChannel.appendLine( @@ -815,14 +861,18 @@ export class Swiftly { `Swift ${version} post-install script executed successfully. Additional system packages have been installed.` ); } catch (error) { - const errorMsg = `Failed to execute post-install script: ${error}`; - logger?.error(errorMsg); - outputChannel.appendLine(""); - outputChannel.appendLine(`Error: ${errorMsg}`); - - void vscode.window.showErrorMessage( - `Failed to execute post-install script for Swift ${version}. Check the output channel for details.` - ); + logger?.error(Error("Failed to execute post-install script", { cause: error })); + void vscode.window + .showErrorMessage( + `Failed to execute post-install script for Swift ${version}. See command output for more details.`, + "Show Command Output" + ) + .then(selected => { + if (!selected) { + return; + } + outputChannel.show(); + }); } } diff --git a/src/toolchain/toolchain.ts b/src/toolchain/toolchain.ts index 5ead0bc01..174bd5e12 100644 --- a/src/toolchain/toolchain.ts +++ b/src/toolchain/toolchain.ts @@ -120,12 +120,21 @@ export class SwiftToolchain { this.swiftVersionString = targetInfo.compilerVersion; } - static async create(folder?: vscode.Uri, logger?: SwiftLogger): Promise { + static async create( + extensionRoot: string, + folder?: vscode.Uri, + logger?: SwiftLogger + ): Promise { const { path: swiftFolderPath, isSwiftlyManaged } = await this.getSwiftFolderPath( folder, logger ); - const toolchainPath = await this.getToolchainPath(swiftFolderPath, folder, logger); + const toolchainPath = await this.getToolchainPath( + swiftFolderPath, + extensionRoot, + folder, + logger + ); const targetInfo = await this.getSwiftTargetInfo( this._getToolchainExecutable(toolchainPath, "swift"), logger @@ -612,6 +621,7 @@ export class SwiftToolchain { */ private static async getToolchainPath( swiftPath: string, + extensionRoot: string, cwd?: vscode.Uri, logger?: SwiftLogger ): Promise { @@ -634,7 +644,11 @@ export class SwiftToolchain { return path.dirname(configuration.path); } - const swiftlyToolchainLocation = await Swiftly.toolchain(logger, cwd); + const swiftlyToolchainLocation = await Swiftly.toolchain( + extensionRoot, + logger, + cwd + ); if (swiftlyToolchainLocation) { return swiftlyToolchainLocation; } diff --git a/test/fixtures.ts b/test/fixtures.ts index 304dd303e..91cfc28e2 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -130,6 +130,20 @@ export interface SwiftTaskFixture { process: TestSwiftProcess; } +/** + * @returns the path of a file in the **dist** directory. + */ +export function distPath(name: string): string { + return path.resolve(__dirname, "../../dist", name); +} + +/** + * @returns the path of a resource in the **assets** directory. + */ +export function assetPath(name: string): string { + return path.resolve(__dirname, "../../assets", name); +} + /** * @returns the path of a resource in the **test** directory. */ diff --git a/test/integration-tests/FolderContext.test.ts b/test/integration-tests/FolderContext.test.ts index 30c3638ce..c1e624486 100644 --- a/test/integration-tests/FolderContext.test.ts +++ b/test/integration-tests/FolderContext.test.ts @@ -72,7 +72,10 @@ suite("FolderContext Error Handling Test Suite", () => { assert.ok(errorLogs.length > 0, "Should log error message with folder context"); assert.ok( - swiftToolchainCreateStub.calledWith(testFolder), + swiftToolchainCreateStub.calledWith( + workspaceContext.extensionContext.extensionPath, + testFolder + ), "Should attempt to create toolchain for specific folder" ); assert.strictEqual( diff --git a/test/integration-tests/SwiftPackage.test.ts b/test/integration-tests/SwiftPackage.test.ts index 6aeee600e..4560e4cc0 100644 --- a/test/integration-tests/SwiftPackage.test.ts +++ b/test/integration-tests/SwiftPackage.test.ts @@ -24,7 +24,7 @@ tag("medium").suite("SwiftPackage Test Suite", function () { let toolchain: SwiftToolchain; setup(async () => { - toolchain = await SwiftToolchain.create(); + toolchain = await SwiftToolchain.create("/path/to/extension"); }); test("No package", async () => { diff --git a/test/integration-tests/askpass/askpass.test.ts b/test/integration-tests/askpass/askpass.test.ts new file mode 100644 index 000000000..d1c392cd5 --- /dev/null +++ b/test/integration-tests/askpass/askpass.test.ts @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import { expect } from "chai"; +import { match } from "sinon"; +import * as vscode from "vscode"; + +import { withAskpassServer } from "@src/askpass/askpass-server"; +import { execFile } from "@src/utilities/utilities"; + +import { mockGlobalObject } from "../../MockUtils"; +import { assetPath, distPath } from "../../fixtures"; + +suite("Askpass Test Suite", () => { + const mockedWindow = mockGlobalObject(vscode, "window"); + const askpassMain = distPath("src/askpass/askpass-main.js"); + const askpassScript = assetPath("swift_askpass.sh"); + + setup(function () { + // The shell script we use won't work on Windows + if (!["darwin", "linux"].includes(process.platform)) { + this.skip(); + } + }); + + test("should prompt the user to enter their password", async () => { + mockedWindow.showInputBox.resolves("super secret password"); + + const output = await withAskpassServer(async (nonce, port) => { + return await execFile(askpassScript, [], { + env: { + ...process.env, + VSCODE_SWIFT_ASKPASS_NODE: process.execPath, + VSCODE_SWIFT_ASKPASS_MAIN: askpassMain, + VSCODE_SWIFT_ASKPASS_NONCE: nonce, + VSCODE_SWIFT_ASKPASS_PORT: port.toString(10), + }, + }); + }); + + expect(output.stdout.trim()).to.equal("super secret password"); + }); + + test("should allow the user to cancel the password input", async () => { + mockedWindow.showInputBox.resolves(undefined); + + const askpassPromise = withAskpassServer(async (nonce, port) => { + return await execFile(askpassScript, [], { + env: { + ...process.env, + VSCODE_SWIFT_ASKPASS_NODE: process.execPath, + VSCODE_SWIFT_ASKPASS_MAIN: askpassMain, + VSCODE_SWIFT_ASKPASS_NONCE: nonce, + VSCODE_SWIFT_ASKPASS_PORT: port.toString(10), + }, + }); + }); + + await expect(askpassPromise).to.eventually.be.rejected; + }); + + test("should reject requests with an invalid nonce", async () => { + mockedWindow.showInputBox.resolves("super secret password"); + + const askpassPromise = withAskpassServer(async (_nonce, port) => { + return await execFile(askpassScript, [], { + env: { + ...process.env, + VSCODE_SWIFT_ASKPASS_NODE: process.execPath, + VSCODE_SWIFT_ASKPASS_MAIN: askpassMain, + VSCODE_SWIFT_ASKPASS_NONCE: "invalid nonce", + VSCODE_SWIFT_ASKPASS_PORT: port.toString(10), + }, + }); + }); + + await expect(askpassPromise).to.eventually.be.rejected; + }); + + test("should be able to control the prompt title", async () => { + mockedWindow.showInputBox.resolves("super secret password"); + + await withAskpassServer( + async (nonce, port) => { + return await execFile(askpassScript, [], { + env: { + ...process.env, + VSCODE_SWIFT_ASKPASS_NODE: process.execPath, + VSCODE_SWIFT_ASKPASS_MAIN: askpassMain, + VSCODE_SWIFT_ASKPASS_NONCE: nonce, + VSCODE_SWIFT_ASKPASS_PORT: port.toString(10), + }, + }); + }, + { title: "An Amazing Title" } + ); + + expect(mockedWindow.showInputBox).to.have.been.calledWith( + match.has("title", "An Amazing Title") + ); + }); +}); diff --git a/test/integration-tests/tasks/SwiftExecution.test.ts b/test/integration-tests/tasks/SwiftExecution.test.ts index 864844782..3efb75310 100644 --- a/test/integration-tests/tasks/SwiftExecution.test.ts +++ b/test/integration-tests/tasks/SwiftExecution.test.ts @@ -29,7 +29,9 @@ suite("SwiftExecution Tests Suite", () => { activateExtensionForSuite({ async setup(ctx) { workspaceContext = ctx; - toolchain = await SwiftToolchain.create(); + toolchain = await SwiftToolchain.create( + workspaceContext.extensionContext.extensionPath + ); assert.notEqual(workspaceContext.folders.length, 0); workspaceFolder = workspaceContext.folders[0].workspaceFolder; }, diff --git a/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts b/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts index 8173b5842..15a2c9e20 100644 --- a/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts +++ b/test/integration-tests/testexplorer/LSPTestDiscovery.test.ts @@ -88,7 +88,7 @@ suite("LSPTestDiscovery Suite", () => { beforeEach(async function () { this.timeout(10000000); - pkg = await SwiftPackage.create(file, await SwiftToolchain.create()); + pkg = await SwiftPackage.create(file, await SwiftToolchain.create("/path/to/extension")); client = new TestLanguageClient(); discoverer = new LSPTestDiscovery( instance( diff --git a/test/integration-tests/testexplorer/TestDiscovery.test.ts b/test/integration-tests/testexplorer/TestDiscovery.test.ts index df1cd7409..62fe6c318 100644 --- a/test/integration-tests/testexplorer/TestDiscovery.test.ts +++ b/test/integration-tests/testexplorer/TestDiscovery.test.ts @@ -223,7 +223,10 @@ suite("TestDiscovery Suite", () => { test("updates tests from classes within a swift package", async () => { const targetFolder = vscode.Uri.file("file:///some/"); - const swiftPackage = await SwiftPackage.create(targetFolder, await SwiftToolchain.create()); + const swiftPackage = await SwiftPackage.create( + targetFolder, + await SwiftToolchain.create("/path/to/extension") + ); const testTargetName = "TestTarget"; const target: Target = { c99name: testTargetName, diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index e915bfff5..dd3c38b55 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -18,6 +18,7 @@ import * as os from "os"; import { match } from "sinon"; import * as vscode from "vscode"; +import * as askpass from "@src/askpass/askpass-server"; import { installSwiftlyToolchainWithProgress } from "@src/commands/installSwiftlyToolchain"; import * as SwiftOutputChannelModule from "@src/logging/SwiftOutputChannel"; import { @@ -30,6 +31,7 @@ import * as utilities from "@src/utilities/utilities"; import { instance, mockGlobalModule, mockGlobalObject, mockGlobalValue } from "../../MockUtils"; suite("Swiftly Unit Tests", () => { + const mockAskpass = mockGlobalModule(askpass); const mockUtilities = mockGlobalModule(utilities); const mockedPlatform = mockGlobalValue(process, "platform"); const mockedEnv = mockGlobalValue(process, "env"); @@ -37,6 +39,7 @@ suite("Swiftly Unit Tests", () => { const mockOS = mockGlobalModule(os); setup(() => { + mockAskpass.withAskpassServer.callsFake(task => task("nonce", 8080)); mockUtilities.execFile.reset(); mockUtilities.execFileStreamOutput.reset(); mockSwiftOutputChannelModule.SwiftOutputChannel.reset(); @@ -462,7 +465,7 @@ suite("Swiftly Unit Tests", () => { mockedPlatform.setValue("win32"); await expect( - Swiftly.installToolchain("6.0.0", undefined) + Swiftly.installToolchain("6.0.0", "/path/to/extension", undefined) ).to.eventually.be.rejectedWith("Swiftly is not supported on this platform"); expect(mockUtilities.execFile).to.not.have.been.called; }); @@ -476,7 +479,7 @@ suite("Swiftly Unit Tests", () => { [tmpDir]: {}, }); - await Swiftly.installToolchain("6.0.0", undefined); + await Swiftly.installToolchain("6.0.0", "/path/to/extension", undefined); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -500,7 +503,7 @@ suite("Swiftly Unit Tests", () => { // This test verifies the method starts the installation process // The actual file stream handling is complex to mock properly try { - await Swiftly.installToolchain("6.0.0", progressCallback); + await Swiftly.installToolchain("6.0.0", "/path/to/extension", progressCallback); } catch (error) { // Expected due to mock-fs limitations with named pipes expect((error as Error).message).to.include("ENOENT"); @@ -520,7 +523,7 @@ suite("Swiftly Unit Tests", () => { }); await expect( - Swiftly.installToolchain("6.0.0", undefined) + Swiftly.installToolchain("6.0.0", "/path/to/extension", undefined) ).to.eventually.be.rejectedWith("Installation failed"); }); }); @@ -861,7 +864,7 @@ suite("Swiftly Unit Tests", () => { mockUtilities.execFileStreamOutput.withArgs("swiftly").resolves(); mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); // Verify swiftly install was called with post-install file argument expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( @@ -879,9 +882,9 @@ suite("Swiftly Unit Tests", () => { mockUtilities.execFileStreamOutput.withArgs("swiftly").rejects(installError); mockUtilities.execFile.withArgs("mkfifo").resolves({ stdout: "", stderr: "" }); - await expect(Swiftly.installToolchain("6.0.0")).to.eventually.be.rejectedWith( - "Swiftly installation failed" - ); + await expect( + Swiftly.installToolchain("6.0.0", "/path/to/extension") + ).to.eventually.be.rejectedWith("Swiftly installation failed"); }); test("should handle mkfifo creation errors", async () => { @@ -891,14 +894,14 @@ suite("Swiftly Unit Tests", () => { const progressCallback = () => {}; await expect( - Swiftly.installToolchain("6.0.0", progressCallback) + Swiftly.installToolchain("6.0.0", "/path/to/extension", progressCallback) ).to.eventually.be.rejectedWith("Cannot create named pipe"); }); test("should install without progress callback successfully", async () => { mockUtilities.execFileStreamOutput.withArgs("swiftly").resolves(); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -919,7 +922,7 @@ suite("Swiftly Unit Tests", () => { const progressCallback = () => {}; try { - await Swiftly.installToolchain("6.0.0", progressCallback); + await Swiftly.installToolchain("6.0.0", "/path/to/extension", progressCallback); } catch (error) { // Expected due to mock-fs limitations with named pipes in this test environment } @@ -981,13 +984,13 @@ apt-get -y install libncurses5-dev`; .withArgs("chmod", match.array) .resolves({ stdout: "", stderr: "" }); - // Mock execFileStreamOutput for pkexec - mockUtilities.execFileStreamOutput.withArgs("pkexec").resolves(); + // Mock execFileStreamOutput for sudo + mockUtilities.execFileStreamOutput.withArgs("sudo").resolves(); // @ts-expect-error mocking vscode window methods makes type checking difficult mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -1004,12 +1007,12 @@ apt-get -y install libncurses5-dev`; ); expect(mockUtilities.execFile).to.have.been.calledWith("chmod", match.array); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( - "pkexec", + "sudo", match.array, match.any, match.any, null, - {} + match.object ); expect(mockVscodeWindow.showInformationMessage).to.have.been.calledWith( match("Swift 6.0.0 post-install script executed successfully") @@ -1037,7 +1040,7 @@ apt-get -y install build-essential`; // @ts-expect-error mocking vscode window methods makes type checking difficult mockVscodeWindow.showWarningMessage.resolves("Cancel"); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -1078,7 +1081,7 @@ apt-get -y install build-essential`; return; }); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -1095,7 +1098,7 @@ apt-get -y install build-essential`; ); expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; expect(mockUtilities.execFileStreamOutput).to.not.have.been.calledWith( - "pkexec", + "sudo", match.array ); }); @@ -1121,15 +1124,16 @@ apt-get -y install build-essential`; .withArgs("chmod", match.array) .resolves({ stdout: "", stderr: "" }); - // Mock execFileStreamOutput for pkexec to throw error + // Mock execFileStreamOutput for sudo to throw error mockUtilities.execFileStreamOutput - .withArgs("pkexec") + .withArgs("sudo") .rejects(new Error("Permission denied")); // @ts-expect-error mocking vscode window methods makes type checking difficult mockVscodeWindow.showWarningMessage.resolves("Execute Script"); + mockVscodeWindow.showErrorMessage.resolves(undefined); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -1153,7 +1157,7 @@ apt-get -y install build-essential`; test("should complete installation successfully when no post-install file exists", async () => { mockUtilities.execFileStreamOutput.withArgs("swiftly").resolves(); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; expect(mockVscodeWindow.showErrorMessage).to.not.have.been.called; @@ -1189,13 +1193,13 @@ yum install ncurses-devel`; .withArgs("chmod", match.array) .resolves({ stdout: "", stderr: "" }); - // Mock execFileStreamOutput for pkexec - mockUtilities.execFileStreamOutput.withArgs("pkexec").resolves(); + // Mock execFileStreamOutput for sudo + mockUtilities.execFileStreamOutput.withArgs("sudo").resolves(); // @ts-expect-error mocking vscode window methods makes type checking difficult mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( match( @@ -1203,12 +1207,12 @@ yum install ncurses-devel`; ) ); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( - "pkexec", + "sudo", match.array, match.any, match.any, null, - {} + match.object ); }); @@ -1231,7 +1235,7 @@ yum remove important-system-package`; return; }); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( "swiftly", @@ -1274,13 +1278,13 @@ apt-get -y install libncurses5-dev .withArgs("chmod", match.array) .resolves({ stdout: "", stderr: "" }); - // Mock execFileStreamOutput for pkexec - mockUtilities.execFileStreamOutput.withArgs("pkexec").resolves(); + // Mock execFileStreamOutput for sudo + mockUtilities.execFileStreamOutput.withArgs("sudo").resolves(); // @ts-expect-error mocking vscode window methods makes type checking difficult mockVscodeWindow.showWarningMessage.resolves("Execute Script"); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockVscodeWindow.showWarningMessage).to.have.been.calledWith( match( @@ -1288,12 +1292,12 @@ apt-get -y install libncurses5-dev ) ); expect(mockUtilities.execFileStreamOutput).to.have.been.calledWith( - "pkexec", + "sudo", match.array, match.any, match.any, null, - {} + match.object ); }); @@ -1301,11 +1305,11 @@ apt-get -y install libncurses5-dev mockedPlatform.setValue("darwin"); mockUtilities.execFileStreamOutput.withArgs("swiftly").resolves(); - await Swiftly.installToolchain("6.0.0"); + await Swiftly.installToolchain("6.0.0", "/path/to/extension"); expect(mockVscodeWindow.showWarningMessage).to.not.have.been.called; expect(mockUtilities.execFileStreamOutput).to.not.have.been.calledWith( - "pkexec", + "sudo", match.array ); }); @@ -1348,7 +1352,7 @@ apt-get -y install libncurses5-dev test("handleMissingSwiftlyToolchain returns false when user declines installation", async () => { mockWindow.showWarningMessage.resolves(undefined); // User cancels/declines - const result = await handleMissingSwiftlyToolchain("6.1.2"); + const result = await handleMissingSwiftlyToolchain("6.1.2", "/path/to/extension"); expect(result).to.be.false; }); @@ -1374,7 +1378,7 @@ apt-get -y install libncurses5-dev .withArgs("swiftly", match.any) .resolves({ stdout: "", stderr: "" }); - const result = await handleMissingSwiftlyToolchain("6.1.2"); + const result = await handleMissingSwiftlyToolchain("6.1.2", "/path/to/extension"); expect(result).to.be.true; }); }); @@ -1402,7 +1406,13 @@ apt-get -y install libncurses5-dev const progressCallback = () => {}; await expect( - Swiftly.installToolchain("6.0.0", progressCallback, undefined, mockToken as any) + Swiftly.installToolchain( + "6.0.0", + "/path/to/extension", + progressCallback, + undefined, + mockToken as any + ) ).to.eventually.be.rejectedWith(Swiftly.cancellationMessage); }); @@ -1421,7 +1431,7 @@ apt-get -y install libncurses5-dev .rejects(new Error(Swiftly.cancellationMessage)); await expect( - Swiftly.installToolchain("6.0.0", undefined, undefined, mockToken) + Swiftly.installToolchain("6.0.0", "", undefined, undefined, mockToken) ).to.eventually.be.rejectedWith(Swiftly.cancellationMessage); }); @@ -1449,7 +1459,7 @@ apt-get -y install libncurses5-dev .withArgs("swiftly") .rejects(new Error(Swiftly.cancellationMessage)); - const result = await installSwiftlyToolchainWithProgress("6.0.0"); + const result = await installSwiftlyToolchainWithProgress("6.0.0", "/path/to/extension"); expect(result).to.be.false; expect(mockWindow.showErrorMessage).to.not.have.been.called; @@ -1472,7 +1482,7 @@ apt-get -y install libncurses5-dev .withArgs("swiftly") .rejects(new Error("Network error")); - const result = await installSwiftlyToolchainWithProgress("6.0.0"); + const result = await installSwiftlyToolchainWithProgress("6.0.0", "/path/to/extension"); expect(result).to.be.false; expect(mockWindow.showErrorMessage).to.have.been.calledWith(