diff --git a/src/toolchain/swiftly.ts b/src/toolchain/swiftly.ts index 85578620f..d5113ea2e 100644 --- a/src/toolchain/swiftly.ts +++ b/src/toolchain/swiftly.ts @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import { ExecFileOptions } from "child_process"; import * as fsSync from "fs"; import * as fs from "fs/promises"; import * as os from "os"; @@ -269,9 +270,9 @@ export class Swiftly { if (!Array.isArray(installedToolchains)) { return []; } - return installedToolchains - .filter((toolchain): toolchain is string => typeof toolchain === "string") - .map(toolchain => path.join(swiftlyHomeDir, "toolchains", toolchain)); + return installedToolchains.filter( + (toolchain): toolchain is string => typeof toolchain === "string" + ); } catch (error) { logger?.error(`Failed to retrieve Swiftly installations: ${error}`); throw new Error( @@ -329,12 +330,21 @@ export class Swiftly { * Instructs Swiftly to use a specific version of the Swift toolchain. * * @param version The version name to use. Obtainable via {@link Swiftly.list}. + * @param [cwd] Optional working directory to set the toolchain within. */ - public static async use(version: string): Promise { + public static async use(version: string, cwd?: string): Promise { if (!this.isSupported()) { throw new Error("Swiftly is not supported on this platform"); } - await execFile("swiftly", ["use", version]); + const useArgs = ["use", "-y"]; + const options: ExecFileOptions = {}; + if (cwd) { + options.cwd = cwd; + } else { + useArgs.push("--global-default"); + } + useArgs.push(version); + await execFile("swiftly", useArgs, options); } /** diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index 2369626ef..93cf89cf2 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -25,7 +25,7 @@ import { showReloadExtensionNotification } from "./ReloadExtension"; /** * Open the installation page on Swift.org */ -export async function downloadToolchain() { +async function downloadToolchain() { if (await vscode.env.openExternal(vscode.Uri.parse("https://www.swift.org/install"))) { const selected = await showReloadExtensionNotification( "The Swift extension must be reloaded once you have downloaded and installed the new toolchain.", @@ -40,7 +40,7 @@ export async function downloadToolchain() { /** * Open the installation page for Swiftly */ -export async function installSwiftly() { +async function installSwiftly() { if (await vscode.env.openExternal(vscode.Uri.parse("https://www.swift.org/install/"))) { const selected = await showReloadExtensionNotification( "The Swift extension must be reloaded once you have downloaded and installed the new toolchain.", @@ -56,7 +56,7 @@ export async function installSwiftly() { * Prompt the user to select a folder where they have installed the swift toolchain. * Updates the swift.path configuration with the selected folder. */ -export async function selectToolchainFolder() { +async function selectToolchainFolder() { const selected = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, @@ -67,7 +67,10 @@ export async function selectToolchainFolder() { if (!selected || selected.length !== 1) { return; } - await setToolchainPath(selected[0].fsPath); + await setToolchainPath({ + category: "public", + swiftFolderPath: selected[0].fsPath, + }); } /** @@ -129,7 +132,7 @@ type SwiftToolchainItem = PublicSwiftToolchainItem | XcodeToolchainItem | Swiftl /** Common properties for a {@link vscode.QuickPickItem} that represents a Swift toolchain */ interface BaseSwiftToolchainItem extends vscode.QuickPickItem { type: "toolchain"; - onDidSelect?(): Promise; + onDidSelect?(target: vscode.ConfigurationTarget): Promise; } /** A {@link vscode.QuickPickItem} for a Swift toolchain that has been installed manually */ @@ -147,17 +150,18 @@ interface XcodeToolchainItem extends BaseSwiftToolchainItem { swiftFolderPath: string; } +/** A {@link vscode.QuickPickItem} for a Swift toolchain provided by Swiftly */ +interface SwiftlyToolchainItem extends BaseSwiftToolchainItem { + category: "swiftly"; + version: string; +} + /** A {@link vscode.QuickPickItem} that performs an action for the user */ interface ActionItem extends vscode.QuickPickItem { type: "action"; run(): Promise; } -interface SwiftlyToolchainItem extends BaseSwiftToolchainItem { - category: "swiftly"; - version: string; -} - /** A {@link vscode.QuickPickItem} that separates items in the UI */ class SeparatorItem implements vscode.QuickPickItem { readonly type = "separator"; @@ -186,28 +190,33 @@ async function getQuickPickItems( cwd?: vscode.Uri ): Promise { // Find any Xcode installations on the system - const xcodes = (await SwiftToolchain.findXcodeInstalls()).map(xcodePath => { - const toolchainPath = path.join( - xcodePath, - "Contents", - "Developer", - "Toolchains", - "XcodeDefault.xctoolchain", - "usr" - ); - return { - type: "toolchain", - category: "xcode", - label: path.basename(xcodePath, ".app"), - detail: xcodePath, - xcodePath, - toolchainPath, - swiftFolderPath: path.join(toolchainPath, "bin"), - }; - }); + const xcodes = (await SwiftToolchain.findXcodeInstalls()) + // Sort in descending order alphabetically + .sort((a, b) => -a.localeCompare(b)) + .map(xcodePath => { + const toolchainPath = path.join( + xcodePath, + "Contents", + "Developer", + "Toolchains", + "XcodeDefault.xctoolchain", + "usr" + ); + return { + type: "toolchain", + category: "xcode", + label: path.basename(xcodePath, ".app"), + detail: xcodePath, + xcodePath, + toolchainPath, + swiftFolderPath: path.join(toolchainPath, "bin"), + }; + }); // Find any public Swift toolchains on the system - const toolchains = (await SwiftToolchain.getToolchainInstalls()).map( - toolchainPath => { + const publicToolchains = (await SwiftToolchain.getToolchainInstalls()) + // Sort in descending order alphabetically + .sort((a, b) => -a.localeCompare(b)) + .map(toolchainPath => { const result: SwiftToolchainItem = { type: "toolchain", category: "public", @@ -225,22 +234,29 @@ async function getQuickPickItems( }; } return result; - } - ); - - // Sort toolchains by label (alphabetically) - const sortedToolchains = toolchains.sort((a, b) => b.label.localeCompare(a.label)); + }); // Find any Swift toolchains installed via Swiftly - const swiftlyToolchains = (await Swiftly.list(logger)).map( - toolchainPath => ({ + const swiftlyToolchains = (await Swiftly.list(logger)) + // Sort in descending order alphabetically + .sort((a, b) => -a.localeCompare(b)) + .map(toolchainPath => ({ type: "toolchain", label: path.basename(toolchainPath), category: "swiftly", version: path.basename(toolchainPath), - onDidSelect: async () => { + onDidSelect: async target => { try { - await Swiftly.use(toolchainPath); + const version = path.basename(toolchainPath); + if (target === vscode.ConfigurationTarget.Global) { + await Swiftly.use(version); + } else { + await Promise.all( + vscode.workspace.workspaceFolders?.map(async folder => { + await Swiftly.use(version, folder.uri.fsPath); + }) ?? [] + ); + } void showReloadExtensionNotification( "Changing the Swift path requires Visual Studio Code be reloaded." ); @@ -251,14 +267,13 @@ async function getQuickPickItems( ); } }, - }) - ); + })); if (activeToolchain) { const currentSwiftlyVersion = activeToolchain.isSwiftlyManaged ? await Swiftly.inUseVersion("swiftly", cwd) : undefined; - const toolchainInUse = [...xcodes, ...sortedToolchains, ...swiftlyToolchains].find( + const toolchainInUse = [...xcodes, ...publicToolchains, ...swiftlyToolchains].find( toolchain => { if (currentSwiftlyVersion) { if (toolchain.category !== "swiftly") { @@ -278,7 +293,7 @@ async function getQuickPickItems( if (toolchainInUse) { toolchainInUse.description = "$(check) in use"; } else { - sortedToolchains.splice(0, 0, { + publicToolchains.splice(0, 0, { type: "toolchain", category: "public", label: `Swift ${activeToolchain.swiftVersion.toString()}`, @@ -311,8 +326,8 @@ async function getQuickPickItems( ? [new SeparatorItem("swiftly"), ...swiftlyToolchains] : []), ...(xcodes.length > 0 ? [new SeparatorItem("Xcode"), ...xcodes] : []), - ...(sortedToolchains.length > 0 - ? [new SeparatorItem("toolchains"), ...sortedToolchains] + ...(publicToolchains.length > 0 + ? [new SeparatorItem("toolchains"), ...publicToolchains] : []), new SeparatorItem("actions"), ...actionItems, @@ -373,7 +388,7 @@ export async function showToolchainSelectionQuickPick( cwd?: vscode.Uri ) { let xcodePaths: string[] = []; - const selected = await vscode.window.showQuickPick( + const selectedToolchain = await vscode.window.showQuickPick( getQuickPickItems(activeToolchain, logger, cwd).then(result => { xcodePaths = result .filter((i): i is XcodeToolchainItem => "category" in i && i.category === "xcode") @@ -386,16 +401,16 @@ export async function showToolchainSelectionQuickPick( canPickMany: false, } ); - if (selected?.type === "action") { - return await selected.run(); + if (selectedToolchain?.type === "action") { + return await selectedToolchain.run(); } - if (selected?.type === "toolchain") { + if (selectedToolchain?.type === "toolchain") { // Select an Xcode to build with let developerDir: string | undefined = undefined; if (process.platform === "darwin") { let selectedXcodePath: string | undefined = undefined; - if (selected.category === "xcode") { - selectedXcodePath = selected.xcodePath; + if (selectedToolchain.category === "xcode") { + selectedXcodePath = selectedToolchain.xcodePath; } else if (xcodePaths.length === 1) { selectedXcodePath = xcodePaths[0]; } else if (xcodePaths.length > 1) { @@ -412,20 +427,8 @@ export async function showToolchainSelectionQuickPick( }); } } - // Update the toolchain path` - let swiftPath: string | undefined; - - if (selected.category === "swiftly") { - swiftPath = undefined; - } else { - // For non-Swiftly toolchains, use the swiftFolderPath - swiftPath = (selected as PublicSwiftToolchainItem | XcodeToolchainItem).swiftFolderPath; - } - - const isUpdated = await setToolchainPath(swiftPath, developerDir); - if (isUpdated && selected.onDidSelect) { - await selected.onDidSelect(); - } + // Update the toolchain configuration + await setToolchainPath(selectedToolchain, developerDir); return; } } @@ -503,48 +506,54 @@ export async function removeToolchainPath() { await swiftSettings.update("path", undefined, vscode.ConfigurationTarget.Workspace); } +async function askWhereToSetToolchain(): Promise { + if (!vscode.workspace.workspaceFolders) { + return vscode.ConfigurationTarget.Global; + } + const selected = await vscode.window.showQuickPick( + [ + { + label: "Workspace Configuration", + description: "(Recommended)", + detail: "Add to VS Code workspace configuration", + target: vscode.ConfigurationTarget.Workspace, + }, + { + label: "Global Configuration", + detail: "Add to VS Code user configuration", + target: vscode.ConfigurationTarget.Global, + }, + ], + { + title: "Toolchain Configuration", + placeHolder: "Select a location to update the toolchain selection", + canPickMany: false, + } + ); + return selected?.target; +} + /** * Update the toolchain path - * @param swiftFolderPath + * @param swiftToolchain * @param developerDir * @returns */ async function setToolchainPath( - swiftFolderPath: string | undefined, + toolchain: { + category: SwiftToolchainItem["category"]; + swiftFolderPath?: string; + onDidSelect?: SwiftToolchainItem["onDidSelect"]; + }, developerDir?: string -): Promise { - let target: vscode.ConfigurationTarget | undefined; - const items: (vscode.QuickPickItem & { - target?: vscode.ConfigurationTarget; - })[] = []; - if (vscode.workspace.workspaceFolders) { - items.push({ - label: "Workspace Configuration", - description: "(Recommended)", - detail: "Add to VS Code workspace configuration", - target: vscode.ConfigurationTarget.Workspace, - }); - } - items.push({ - label: "User Configuration", - detail: "Add to VS Code user configuration.", - target: vscode.ConfigurationTarget.Global, - }); - if (items.length > 1) { - const selected = await vscode.window.showQuickPick(items, { - title: "Toolchain Configuration", - placeHolder: "Select a location to update the toolchain selection", - canPickMany: false, - }); - if (!selected) { - return false; - } - target = selected.target; - } else { - target = vscode.ConfigurationTarget.Global; // Global scope by default +): Promise { + const target = await askWhereToSetToolchain(); + if (!target) { + return; } + const toolchainPath = toolchain.category !== "swiftly" ? toolchain.swiftFolderPath : undefined; const swiftConfiguration = vscode.workspace.getConfiguration("swift"); - await swiftConfiguration.update("path", swiftFolderPath, target); + await swiftConfiguration.update("path", toolchainPath, target); const swiftEnv = configuration.swiftEnvironmentVariables; await swiftConfiguration.update( "swiftEnvironmentVariables", @@ -555,7 +564,9 @@ async function setToolchainPath( target ); await checkAndRemoveWorkspaceSetting(target); - return true; + if (toolchain.onDidSelect) { + await toolchain.onDidSelect(target); + } } async function checkAndRemoveWorkspaceSetting(target: vscode.ConfigurationTarget | undefined) { diff --git a/test/unit-tests/toolchain/swiftly.test.ts b/test/unit-tests/toolchain/swiftly.test.ts index fa6770881..f88badf43 100644 --- a/test/unit-tests/toolchain/swiftly.test.ts +++ b/test/unit-tests/toolchain/swiftly.test.ts @@ -63,15 +63,54 @@ suite("Swiftly Unit Tests", () => { mockFS.restore(); }); + suite("use()", () => { + test("sets the global toolchain if no cwd is provided", async () => { + // Mock version check to return 1.0.1 + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + // "swiftly use" succeeds + mockUtilities.execFile.withArgs("swiftly", match.array.startsWith(["use"])).resolves(); + + await Swiftly.use("6.1.0"); + + expect(mockUtilities.execFile).to.have.been.calledWith("swiftly", [ + "use", + "-y", + "--global-default", + "6.1.0", + ]); + }); + + test("sets the toolchain in cwd if it is provided", async () => { + // Mock version check to return 1.0.1 + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.1.0\n", + stderr: "", + }); + // "swiftly use" succeeds + mockUtilities.execFile.withArgs("swiftly", match.array.startsWith(["use"])).resolves(); + + await Swiftly.use("6.1.0", "/home/user/project"); + + expect(mockUtilities.execFile).to.have.been.calledWith( + "swiftly", + ["use", "-y", "6.1.0"], + match.has("cwd", "/home/user/project") + ); + }); + }); + suite("list()", () => { - test("should return toolchain names from list-available command for version 1.1.0", async () => { + test("should return toolchain names from list command for version 1.1.0", async () => { // Mock version check to return 1.1.0 mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ stdout: "1.1.0\n", stderr: "", }); - // Mock list-available command with JSON output + // Mock list command with JSON output const jsonOutput = { toolchains: [ { @@ -233,6 +272,26 @@ suite("Swiftly Unit Tests", () => { ]); }); + test("should return toolchain names from the configuration file for version 1.0.1", async () => { + // Mock version check to return 1.0.1 + mockUtilities.execFile.withArgs("swiftly", ["--version"]).resolves({ + stdout: "1.0.1\n", + stderr: "", + }); + + // Swiftly home directory contains a config.json + mockedEnv.setValue({ SWIFTLY_HOME_DIR: "/home/.swiftly" }); + mockFS({ + "/home/.swiftly/config.json": JSON.stringify({ + installedToolchains: ["swift-5.9.0", "swift-6.0.0"], + }), + }); + + const result = await Swiftly.list(); + + expect(result).to.deep.equal(["swift-5.9.0", "swift-6.0.0"]); + }); + test("should return empty array when platform is not supported", async () => { mockedPlatform.setValue("win32"); diff --git a/test/unit-tests/toolchain/toolchain.test.ts b/test/unit-tests/toolchain/toolchain.test.ts index 8af82308e..965d617e9 100644 --- a/test/unit-tests/toolchain/toolchain.test.ts +++ b/test/unit-tests/toolchain/toolchain.test.ts @@ -15,7 +15,6 @@ import { expect } from "chai"; import * as mockFS from "mock-fs"; import * as path from "path"; -import { Swiftly } from "@src/toolchain/swiftly"; import { SwiftToolchain } from "@src/toolchain/toolchain"; import * as utilities from "@src/utilities/utilities"; import { Version } from "@src/utilities/version"; @@ -299,106 +298,4 @@ suite("SwiftToolchain Unit Test Suite", () => { await expect(SwiftToolchain.findXcodeInstalls()).to.eventually.be.empty; }); }); - - suite("getSwiftlyToolchainInstalls()", () => { - const mockedEnv = mockGlobalValue(process, "env"); - - test("returns installed toolchains on Linux", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - installedToolchains: ["swift-5.9.0", "swift-6.0.0"], - }), - }); - - const toolchains = await Swiftly.list(); - expect(toolchains).to.deep.equal([ - path.join(mockHomeDir, "toolchains", "swift-5.9.0"), - path.join(mockHomeDir, "toolchains", "swift-6.0.0"), - ]); - }); - - test("returns installed toolchains on macOS", async () => { - mockedPlatform.setValue("darwin"); - const mockHomeDir = "/Users/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - installedToolchains: ["swift-5.9.0", "swift-6.0.0"], - }), - }); - - const toolchains = await Swiftly.list(); - expect(toolchains).to.deep.equal([ - path.join(mockHomeDir, "toolchains", "swift-5.9.0"), - path.join(mockHomeDir, "toolchains", "swift-6.0.0"), - ]); - }); - - test("returns empty array when SWIFTLY_HOME_DIR is not set", async () => { - mockedPlatform.setValue("linux"); - mockedEnv.setValue({}); - - const toolchains = await Swiftly.list(); - expect(toolchains).to.be.empty; - }); - - test("returns empty array when config file does not exist", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({}); - - await expect(Swiftly.list()).to.be.rejected.then(error => { - expect(error.message).to.include( - "Failed to retrieve Swiftly installations from disk" - ); - }); - }); - - test("returns empty array when config has no installedToolchains", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - someOtherProperty: "value", - }), - }); - - const toolchains = await Swiftly.list(); - expect(toolchains).to.be.empty; - }); - - test("returns empty array on Windows", async () => { - mockedPlatform.setValue("win32"); - const toolchains = await Swiftly.list(); - expect(toolchains).to.be.empty; - }); - - test("filters out non-string toolchain entries", async () => { - mockedPlatform.setValue("linux"); - const mockHomeDir = "/home/user/.swiftly"; - mockedEnv.setValue({ SWIFTLY_HOME_DIR: mockHomeDir }); - - mockFS({ - [path.join(mockHomeDir, "config.json")]: JSON.stringify({ - installedToolchains: ["swift-5.9.0", null, "swift-6.0.0", 123, "swift-6.1.0"], - }), - }); - - const toolchains = await Swiftly.list(); - expect(toolchains).to.deep.equal([ - path.join(mockHomeDir, "toolchains", "swift-5.9.0"), - path.join(mockHomeDir, "toolchains", "swift-6.0.0"), - path.join(mockHomeDir, "toolchains", "swift-6.1.0"), - ]); - }); - }); }); diff --git a/test/unit-tests/ui/ToolchainSelection.test.ts b/test/unit-tests/ui/ToolchainSelection.test.ts index 97f6907c4..98d2dd65b 100644 --- a/test/unit-tests/ui/ToolchainSelection.test.ts +++ b/test/unit-tests/ui/ToolchainSelection.test.ts @@ -13,18 +13,25 @@ //===----------------------------------------------------------------------===// import { expect } from "chai"; import * as mockFS from "mock-fs"; -import * as sinon from "sinon"; -import { match, stub } from "sinon"; +import * as path from "path"; +import { match } from "sinon"; import * as vscode from "vscode"; import { SwiftLogger } from "@src/logging/SwiftLogger"; import { Swiftly } from "@src/toolchain/swiftly"; import { SwiftToolchain } from "@src/toolchain/toolchain"; -import * as ToolchainSelectionModule from "@src/ui/ToolchainSelection"; +import { showToolchainSelectionQuickPick } from "@src/ui/ToolchainSelection"; import * as utilities from "@src/utilities/utilities"; -import { Version } from "@src/utilities/version"; -import { mockGlobalModule, mockGlobalObject, mockGlobalValue } from "../../MockUtils"; +import { + MockedObject, + instance, + mockFn, + mockGlobalModule, + mockGlobalObject, + mockGlobalValue, + mockObject, +} from "../../MockUtils"; suite("ToolchainSelection Unit Test Suite", () => { const mockedUtilities = mockGlobalModule(utilities); @@ -33,18 +40,18 @@ suite("ToolchainSelection Unit Test Suite", () => { const mockedVSCodeCommands = mockGlobalObject(vscode, "commands"); const mockedVSCodeEnv = mockGlobalObject(vscode, "env"); const mockedVSCodeWorkspace = mockGlobalObject(vscode, "workspace"); - let mockLogger: SwiftLogger; + const mockedSwiftToolchain = mockGlobalModule(SwiftToolchain); + const mockedSwiftly = mockGlobalModule(Swiftly); + let mockedConfiguration: MockedObject; + let mockedLogger: MockedObject; setup(() => { mockFS({}); - mockedUtilities.execFile.reset(); - mockedPlatform.setValue("darwin"); + mockedUtilities.execFile.rejects( + new Error("execFile was not properly mocked for this test.") + ); - mockLogger = { - info: () => {}, - warn: () => {}, - error: () => {}, - } as unknown as SwiftLogger; + mockedLogger = mockObject({}); // Set up VSCode mocks mockedVSCodeWindow.showQuickPick.resolves(undefined); @@ -59,266 +66,317 @@ suite("ToolchainSelection Unit Test Suite", () => { mockedVSCodeEnv.openExternal.resolves(true); // Mock workspace configuration to prevent actual settings writes - const mockConfiguration = { - update: stub().resolves(), - inspect: stub().returns({}), - get: stub().returns(undefined), - has: stub().returns(false), - }; - mockedVSCodeWorkspace.getConfiguration.returns(mockConfiguration); + mockedConfiguration = mockObject({ + update: mockFn(), + inspect: mockFn(s => s.returns({})), + get: mockFn(), + has: mockFn(s => s.returns(false)), + }); + mockedVSCodeWorkspace.getConfiguration.returns(instance(mockedConfiguration)); + mockedVSCodeWorkspace.workspaceFolders = [ + { + index: 0, + name: "test", + uri: vscode.Uri.file("/path/to/workspace"), + }, + ]; // Mock SwiftToolchain static methods - stub(SwiftToolchain, "findXcodeInstalls").resolves([]); - stub(SwiftToolchain, "getToolchainInstalls").resolves([]); - stub(SwiftToolchain, "getXcodeDeveloperDir").resolves(""); + mockedSwiftToolchain.findXcodeInstalls.resolves([]); + mockedSwiftToolchain.getToolchainInstalls.resolves([]); + mockedSwiftToolchain.getXcodeDeveloperDir.resolves(""); // Mock Swiftly static methods - stub(Swiftly, "list").resolves([]); - stub(Swiftly, "listAvailable").resolves([]); - stub(Swiftly, "inUseVersion").resolves(undefined); - stub(Swiftly, "use").resolves(); - stub(Swiftly, "installToolchain").resolves(); + mockedSwiftly.list.resolves([]); + mockedSwiftly.listAvailable.resolves([]); + mockedSwiftly.inUseVersion.resolves(undefined); + mockedSwiftly.use.resolves(); + mockedSwiftly.installToolchain.resolves(); }); teardown(() => { mockFS.restore(); - sinon.restore(); }); - suite("showToolchainSelectionQuickPick", () => { - function createMockActiveToolchain(options: { - swiftVersion: Version; - toolchainPath: string; - swiftFolderPath: string; - isSwiftlyManaged?: boolean; - }): SwiftToolchain { - return { - swiftVersion: options.swiftVersion, - toolchainPath: options.toolchainPath, - swiftFolderPath: options.swiftFolderPath, - isSwiftlyManaged: options.isSwiftlyManaged || false, - } as SwiftToolchain; - } - - test("should show quick pick with toolchain options", async () => { - const xcodeInstalls = ["/Applications/Xcode.app"]; - const toolchainInstalls = [ - "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", - ]; - const swiftlyToolchains = ["swift-6.0.0"]; - const availableToolchains = [ - { - name: "6.0.1", - type: "stable" as const, - version: "6.0.1", - isInstalled: false, - }, - ]; - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves(xcodeInstalls); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves(toolchainInstalls); - (Swiftly.list as sinon.SinonStub).resolves(swiftlyToolchains); - (Swiftly.listAvailable as sinon.SinonStub).resolves(availableToolchains); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - expect(SwiftToolchain.findXcodeInstalls).to.have.been.called; - expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; - expect(Swiftly.list).to.have.been.called; + suite("macOS", () => { + setup(() => { + mockedPlatform.setValue("darwin"); }); - test("should work on Linux platform", async () => { - mockedPlatform.setValue("linux"); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.list as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; - expect(Swiftly.list).to.have.been.called; + test("shows Xcode toolchains", async () => { + mockedSwiftToolchain.findXcodeInstalls.resolves([ + "/Applications/Xcode-beta.app", + "/Applications/Xcode.app", + ]); + // Extract the Xcode toolchain labels and simulate user cancellation + let xcodeToolchains: string[] = []; + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + xcodeToolchains = (await items) + .filter((t: any) => t.category === "xcode") + .map((t: any) => t.label); + return undefined; + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(xcodeToolchains).to.deep.equal(["Xcode", "Xcode-beta"]); }); - test("should handle active toolchain correctly", async () => { - const activeToolchain = createMockActiveToolchain({ - swiftVersion: new Version(6, 0, 1), - toolchainPath: "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr", - swiftFolderPath: - "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr/bin", - isSwiftlyManaged: false, - }); - - const toolchainInstalls = [ - "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", - ]; - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves(toolchainInstalls); - (Swiftly.list as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick( - activeToolchain, - mockLogger + test("user is able to set an Xcode toolchain for their workspace", async () => { + mockedSwiftToolchain.findXcodeInstalls.resolves(["/Applications/Xcode.app"]); + // User selects the first toolchain that appears + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + const xcodeToolchains = (await items).filter( + (t: any) => t.category === "xcode" + ); + return xcodeToolchains[0]; + }); + // User selects Workspace Configuration + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Toolchain Configuration")) + .callsFake(async items => { + return (await items).find(item => item.label === "Workspace Configuration"); + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(mockedConfiguration.update).to.have.been.calledWith( + "path", + path.normalize( + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin" + ), + vscode.ConfigurationTarget.Workspace ); - - expect(SwiftToolchain.getToolchainInstalls).to.have.been.called; }); - test("should handle Swiftly managed active toolchain", async () => { - const activeToolchain = createMockActiveToolchain({ - swiftVersion: new Version(6, 0, 0), - toolchainPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr", - swiftFolderPath: "/home/user/.swiftly/toolchains/swift-6.0.0/usr/bin", - isSwiftlyManaged: true, - }); - - const swiftlyToolchains = ["6.0.0", "6.1.0"]; - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.list as sinon.SinonStub).resolves(swiftlyToolchains); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - (Swiftly.inUseVersion as sinon.SinonStub).resolves("6.0.0"); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick( - activeToolchain, - mockLogger + test("user is able to set a global Xcode toolchain", async () => { + mockedSwiftToolchain.findXcodeInstalls.resolves(["/Applications/Xcode.app"]); + // User selects the first toolchain that appears + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + const xcodeToolchains = (await items).filter( + (t: any) => t.category === "xcode" + ); + return xcodeToolchains[0]; + }); + // User selects Global Configuration + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Toolchain Configuration")) + .callsFake(async items => { + return (await items).find(item => item.label === "Global Configuration"); + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(mockedConfiguration.update).to.have.been.calledWith( + "path", + path.normalize( + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin" + ), + vscode.ConfigurationTarget.Global ); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; }); - test("should handle toolchain installation selection", async () => { - const installableToolchain = { - type: "toolchain", - category: "installable", - label: "$(cloud-download) 6.0.1 (stable)", - version: "6.0.1", - toolchainType: "stable", - onDidSelect: stub().resolves(), - }; - - mockedVSCodeWindow.showQuickPick.resolves(installableToolchain as any); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.list as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([ - { - name: "6.0.1", - type: "stable" as const, - version: "6.0.1", - isInstalled: false, - }, + test("shows public toolchains installed by the user", async () => { + mockedSwiftToolchain.getToolchainInstalls.resolves([ + "/Library/Developer/Toolchains/swift-main-DEVELOPMENT", + "/Library/Developer/Toolchains/swift-6.2-RELEASE", ]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; + // Extract the Xcode toolchain labels and simulate user cancellation + let publicToolchains: string[] = []; + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + publicToolchains = (await items) + .filter((t: any) => t.category === "public") + .map((t: any) => t.label); + return undefined; + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(publicToolchains).to.deep.equal(["swift-main-DEVELOPMENT", "swift-6.2-RELEASE"]); }); - test("should handle action item selection", async () => { - const actionItem = { - type: "action", - label: "$(cloud-download) Download from Swift.org...", - run: stub().resolves(), - }; - - mockedVSCodeWindow.showQuickPick.resolves(actionItem as any); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.list as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(actionItem.run).to.have.been.called; + test("shows toolchains installed via Swiftly", async () => { + mockedSwiftly.list.resolves(["6.0.0", "6.2.0", "5.9.3"]); + // Extract the Swiftly toolchain labels and simulate user cancellation + let swiftlyToolchains: string[] = []; + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + swiftlyToolchains = (await items) + .filter((t: any) => t.category === "swiftly") + .map((t: any) => t.label); + return undefined; + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(swiftlyToolchains).to.deep.equal(["6.2.0", "6.0.0", "5.9.3"]); }); - test("should handle user cancellation", async () => { - mockedVSCodeWindow.showQuickPick.resolves(undefined); - - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).resolves([]); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).resolves([]); - (Swiftly.list as sinon.SinonStub).resolves([]); - (Swiftly.listAvailable as sinon.SinonStub).resolves([]); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - // Should complete without error when user cancels - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; - }); - - test("should handle errors gracefully", async () => { - (SwiftToolchain.findXcodeInstalls as sinon.SinonStub).rejects( - new Error("Xcode search failed") - ); - (SwiftToolchain.getToolchainInstalls as sinon.SinonStub).rejects( - new Error("Toolchain search failed") + test("user is able to set a Swiftly toolchain for their workspace", async () => { + mockedSwiftly.list.resolves(["6.2.0"]); + mockedSwiftToolchain.findXcodeInstalls.resolves(["/Applications/Xcode.app"]); + // User selects the first toolchain that appears + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + const swiftlyToolchains = (await items).filter( + (t: any) => t.category === "swiftly" + ); + return swiftlyToolchains[0]; + }); + // User selects Workspace Configuration + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Toolchain Configuration")) + .callsFake(async items => { + return (await items).find(item => item.label === "Workspace Configuration"); + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(mockedSwiftly.use).to.have.been.calledWith( + "6.2.0", + path.normalize("/path/to/workspace") ); - (Swiftly.list as sinon.SinonStub).rejects(new Error("Swiftly list failed")); - (Swiftly.listAvailable as sinon.SinonStub).rejects( - new Error("Swiftly available failed") + expect(mockedConfiguration.update).to.have.been.calledWith( + "path", + undefined, + vscode.ConfigurationTarget.Workspace ); - - await ToolchainSelectionModule.showToolchainSelectionQuickPick(undefined, mockLogger); - - expect(mockedVSCodeWindow.showQuickPick).to.have.been.called; }); - }); - suite("downloadToolchain", () => { - test("should open external URL for Swift.org", async () => { - mockedVSCodeEnv.openExternal.resolves(true); - - await ToolchainSelectionModule.downloadToolchain(); - - expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( - match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install") + test("user is able to set a global Swiftly toolchain", async () => { + mockedSwiftly.list.resolves(["6.2.0"]); + mockedSwiftToolchain.findXcodeInstalls.resolves(["/Applications/Xcode.app"]); + mockedVSCodeWorkspace.workspaceFolders = [ + { + index: 0, + name: "test", + uri: vscode.Uri.file("/path/to/workspace"), + }, + ]; + // User selects the first toolchain that appears + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + const swiftlyToolchains = (await items).filter( + (t: any) => t.category === "swiftly" + ); + return swiftlyToolchains[0]; + }); + // User selects Global Configuration + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Toolchain Configuration")) + .callsFake(async items => { + return (await items).find(item => item.label === "Global Configuration"); + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(mockedSwiftly.use).to.have.been.calledWith("6.2.0"); + expect(mockedConfiguration.update).to.have.been.calledWith( + "path", + undefined, + vscode.ConfigurationTarget.Global ); }); }); - suite("installSwiftly", () => { - test("should open external URL for Swiftly installation", async () => { - mockedVSCodeEnv.openExternal.resolves(true); - - await ToolchainSelectionModule.installSwiftly(); - - expect(mockedVSCodeEnv.openExternal).to.have.been.calledWith( - match((uri: vscode.Uri) => uri.toString() === "https://www.swift.org/install/") - ); + suite("Linux", () => { + setup(() => { + mockedPlatform.setValue("linux"); }); - }); - - suite("selectToolchainFolder", () => { - test("should show open dialog for folder selection", async () => { - const selectedFolder = [{ fsPath: "/custom/toolchain/path" }] as vscode.Uri[]; - mockedVSCodeWindow.showOpenDialog.resolves(selectedFolder); - await ToolchainSelectionModule.selectToolchainFolder(); - - expect(mockedVSCodeWindow.showOpenDialog).to.have.been.calledWith({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: "Select the folder containing Swift binaries", - openLabel: "Select folder", - }); + test("shows toolchains installed via Swiftly", async () => { + mockedSwiftly.list.resolves(["6.0.0", "6.2.0", "5.9.3"]); + // Extract the Swiftly toolchain labels and simulate user cancellation + let swiftlyToolchains: string[] = []; + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + swiftlyToolchains = (await items) + .filter((t: any) => t.category === "swiftly") + .map((t: any) => t.label); + return undefined; + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(swiftlyToolchains).to.deep.equal(["6.2.0", "6.0.0", "5.9.3"]); }); - test("should handle user cancellation", async () => { - mockedVSCodeWindow.showOpenDialog.resolves(undefined); - - await ToolchainSelectionModule.selectToolchainFolder(); + test("user is able to set a Swiftly toolchain for their workspace", async () => { + mockedSwiftly.list.resolves(["6.2.0"]); + // User selects the first toolchain that appears + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + const swiftlyToolchains = (await items).filter( + (t: any) => t.category === "swiftly" + ); + return swiftlyToolchains[0]; + }); + // User selects Workspace Configuration + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Toolchain Configuration")) + .callsFake(async items => { + return (await items).find(item => item.label === "Workspace Configuration"); + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(mockedSwiftly.use).to.have.been.calledWith( + "6.2.0", + path.normalize("/path/to/workspace") + ); + expect(mockedConfiguration.update).to.have.been.calledWith( + "path", + undefined, + vscode.ConfigurationTarget.Workspace + ); + }); - expect(mockedVSCodeWindow.showOpenDialog).to.have.been.called; + test("user is able to set a global Swiftly toolchain", async () => { + mockedSwiftly.list.resolves(["6.2.0"]); + mockedVSCodeWorkspace.workspaceFolders = [ + { + index: 0, + name: "test", + uri: vscode.Uri.file("/path/to/workspace"), + }, + ]; + // User selects the first toolchain that appears + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Select the Swift toolchain")) + .callsFake(async items => { + const swiftlyToolchains = (await items).filter( + (t: any) => t.category === "swiftly" + ); + return swiftlyToolchains[0]; + }); + // User selects Global Configuration + mockedVSCodeWindow.showQuickPick + .withArgs(match.any, match.has("title", "Toolchain Configuration")) + .callsFake(async items => { + return (await items).find(item => item.label === "Global Configuration"); + }); + + await showToolchainSelectionQuickPick(undefined, instance(mockedLogger)); + + expect(mockedSwiftly.use).to.have.been.calledWith("6.2.0"); + expect(mockedConfiguration.update).to.have.been.calledWith( + "path", + undefined, + vscode.ConfigurationTarget.Global + ); }); }); });