From 474e367d1135d3cb813bdf461729c5998e97a3d1 Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Tue, 8 Oct 2024 09:53:11 -0400 Subject: [PATCH 1/7] add ability for mockGlobalModule() to mock only specific module properties --- test/MockUtils.ts | 55 ++++++++++++++++++------------- test/unit-tests/MockUtils.test.ts | 31 +++++++++++------ 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/test/MockUtils.ts b/test/MockUtils.ts index a3dcdd8a0..ec9ec7e37 100644 --- a/test/MockUtils.ts +++ b/test/MockUtils.ts @@ -127,6 +127,23 @@ function replaceWithMocks(obj: Partial): MockedObject { return result; } +function checkAndAcquireValueFromTarget( + target: any, + property: string | symbol, + method: "access" | "set" +): any { + if (!Object.prototype.hasOwnProperty.call(target, property)) { + throw new Error( + `Attempted to ${method} property '${String(property)}', but it was not mocked.` + ); + } + const value = target[property]; + if (value && Object.prototype.hasOwnProperty.call(value, "_wasThrownByRealObject")) { + throw value; + } + return value; +} + /** * Creates a MockedObject from an interface or class. Converts any functions into SinonStubs. * @@ -145,24 +162,12 @@ function replaceWithMocks(obj: Partial): MockedObject { */ export function mockObject(overrides: Partial): MockedObject { const clonedObject = replaceWithMocks(overrides); - function checkAndAcquireValueFromTarget(target: any, property: string | symbol): any { - if (!Object.prototype.hasOwnProperty.call(target, property)) { - throw new Error( - `Attempted to access property '${String(property)}', but it was not mocked.` - ); - } - const value = target[property]; - if (value && Object.prototype.hasOwnProperty.call(value, "_wasThrownByRealObject")) { - throw value; - } - return value; - } return new Proxy(clonedObject, { get(target, property) { - return checkAndAcquireValueFromTarget(target, property); + return checkAndAcquireValueFromTarget(target, property, "access"); }, set(target, property, value) { - checkAndAcquireValueFromTarget(target, property); + checkAndAcquireValueFromTarget(target, property, "set"); target[property] = value; return true; }, @@ -297,17 +302,18 @@ function shallowClone(obj: T): T { * * **Note:** This **MUST** be called outside of the test() function or it will not work. * - * @param mod The module that will be fully mocked + * @param module The module that will be fully mocked + * @param overrides Used to mock only certain properties within the module */ -export function mockGlobalModule(mod: T): MockedObject { +export function mockGlobalModule(module: T, overrides?: Partial): MockedObject { let realMock: MockedObject; - const originalValue: T = shallowClone(mod); + const originalValue: T = shallowClone(module); // Create the mock at setup setup(() => { - realMock = mockObject(mod); + realMock = mockObject(overrides ?? module); for (const property of Object.getOwnPropertyNames(realMock)) { try { - Object.defineProperty(mod, property, { + Object.defineProperty(module, property, { value: (realMock as any)[property], writable: true, }); @@ -320,7 +326,7 @@ export function mockGlobalModule(mod: T): MockedObject { teardown(() => { for (const property of Object.getOwnPropertyNames(originalValue)) { try { - Object.defineProperty(mod, property, { + Object.defineProperty(module, property, { value: (originalValue as any)[property], }); } catch { @@ -334,10 +340,15 @@ export function mockGlobalModule(mod: T): MockedObject { if (!realMock) { throw Error("Mock proxy accessed before setup()"); } - return (mod as any)[property]; + checkAndAcquireValueFromTarget(realMock, property, "access"); + return (module as any)[property]; }, set(target, property, value) { - (mod as any)[property] = value; + if (!realMock) { + throw Error("Mock proxy accessed before setup()"); + } + checkAndAcquireValueFromTarget(realMock, property, "set"); + (module as any)[property] = value; return true; }, }); diff --git a/test/unit-tests/MockUtils.test.ts b/test/unit-tests/MockUtils.test.ts index c44bad9e5..4ca2fb65f 100644 --- a/test/unit-tests/MockUtils.test.ts +++ b/test/unit-tests/MockUtils.test.ts @@ -16,6 +16,7 @@ import { expect } from "chai"; import { stub } from "sinon"; import * as vscode from "vscode"; import * as fs from "fs/promises"; +import * as os from "os"; import { AsyncEventEmitter, mockFn, @@ -207,41 +208,51 @@ suite("MockUtils Test Suite", () => { suite("mockGlobalModule()", () => { const mockedFS = mockGlobalModule(fs); + const mockedOS = mockGlobalModule(os, { homedir: () => "" }); const mockedContextKeys = mockGlobalModule(contextKeys); const mockedConfiguration = mockGlobalModule(configuration); - test("can mock the fs/promises module", async () => { + test("replaces functions within the module with Sinon stubs", async () => { mockedFS.readFile.resolves("file contents"); await expect(fs.readFile("some_file")).to.eventually.equal("file contents"); expect(mockedFS.readFile).to.have.been.calledOnceWithExactly("some_file"); }); - test("can mock the contextKeys module", () => { + test("replaces properties within the module with writable values", () => { // Initial value should be undefined expect(contextKeys.isActivated).to.be.undefined; + expect(mockedContextKeys.isActivated).to.be.undefined; // Make sure that you can set the value of contextKeys using the mock mockedContextKeys.isActivated = true; expect(contextKeys.isActivated).to.be.true; + expect(mockedContextKeys.isActivated).to.be.true; // Make sure that setting isActivated via contextKeys is also possible contextKeys.isActivated = false; expect(contextKeys.isActivated).to.be.false; + expect(mockedContextKeys.isActivated).to.be.false; }); - test("can mock the configuration module", () => { - expect(configuration.sdk).to.equal(""); - // Make sure you can set a value using the mock - mockedConfiguration.sdk = "macOS"; - expect(configuration.sdk).to.equal("macOS"); - // Make sure you can set a value using the real module - configuration.sdk = "watchOS"; - expect(configuration.sdk).to.equal("watchOS"); + test("allows deeply nested mocks for the configuration module", () => { // Mocking objects within the configuration requires separate MockedObjects const mockedLspConfig = mockObject<(typeof configuration)["lsp"]>(configuration.lsp); mockedConfiguration.lsp = mockedLspConfig; mockedLspConfig.disable = true; expect(configuration.lsp.disable).to.be.true; }); + + test("can be used to mock only certain properties within a global module", () => { + mockedOS.homedir.returns("/path/to/my/custom/homedir"); + expect(os.homedir()).to.equal("/path/to/my/custom/homedir"); + // Make sure that accessing non-mocked properties of the mocked module fails + expect(() => mockedOS.arch.returns("arm64")).to.throw( + "Attempted to access property 'arch', but it was not mocked" + ); + // Make sure that setting non-mocked properties on the mocked module fails + expect(() => (mockedOS.EOL = "\n")).to.throw( + "Attempted to set property 'EOL', but it was not mocked" + ); + }); }); suite("mockGlobalValue()", () => { From f73d94f31b7ebfeb6fb1964615f14aea842ccb00 Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Tue, 8 Oct 2024 11:00:59 -0400 Subject: [PATCH 2/7] add basic toolchain tests --- src/toolchain/toolchain.ts | 4 +- src/ui/ToolchainSelection.ts | 4 +- test/unit-tests/toolchain/toolchain.test.ts | 227 ++++++++++++++++++ test/unit-tests/ui/ToolchainSelection.test.ts | 66 +++++ 4 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 test/unit-tests/ui/ToolchainSelection.test.ts diff --git a/src/toolchain/toolchain.ts b/src/toolchain/toolchain.ts index c759ecece..1254516d9 100644 --- a/src/toolchain/toolchain.ts +++ b/src/toolchain/toolchain.ts @@ -255,7 +255,7 @@ export class SwiftToolchain { .filter((toolchain): toolchain is string => typeof toolchain === "string") .map(toolchain => path.join(swiftlyHomeDir, "toolchains", toolchain)); } catch (error) { - throw new Error("Failed to retrieve Swiftly installations from disk."); + return []; } } @@ -271,7 +271,7 @@ export class SwiftToolchain { return Promise.all([ this.findToolchainsIn("/Library/Developer/Toolchains/"), this.findToolchainsIn(path.join(os.homedir(), "Library/Developer/Toolchains/")), - ]).then(results => results.flatMap(a => a)); + ]).then(results => results.flat()); } /** diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index f21d8b94a..4090021f7 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -144,7 +144,7 @@ async function getQuickPickItems( ): Promise { // Find any Xcode installations on the system const xcodes = (await SwiftToolchain.getXcodeInstalls()) - .reverse() + .sort((a, b) => path.basename(a, ".app").localeCompare(path.basename(b, ".app"))) .map(xcodePath => { const toolchainPath = path.join( xcodePath, @@ -255,7 +255,7 @@ async function getQuickPickItems( * * @param activeToolchain the {@link WorkspaceContext} */ -export async function showToolchainSelectionQuickPick(activeToolchain: SwiftToolchain | undefined) { +export async function showToolchainSelectionQuickPick(activeToolchain?: SwiftToolchain) { let xcodePaths: string[] = []; const selected = await vscode.window.showQuickPick( getQuickPickItems(activeToolchain).then(result => { diff --git a/test/unit-tests/toolchain/toolchain.test.ts b/test/unit-tests/toolchain/toolchain.test.ts index cc491fc7f..1af82a63a 100644 --- a/test/unit-tests/toolchain/toolchain.test.ts +++ b/test/unit-tests/toolchain/toolchain.test.ts @@ -14,6 +14,7 @@ import { expect } from "chai"; import * as mockFS from "mock-fs"; +import * as os from "os"; import * as utilities from "../../../src/utilities/utilities"; import { SwiftToolchain } from "../../../src/toolchain/toolchain"; import { Version } from "../../../src/utilities/version"; @@ -22,6 +23,8 @@ import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; suite("SwiftToolchain Unit Test Suite", () => { const mockedUtilities = mockGlobalModule(utilities); const mockedPlatform = mockGlobalValue(process, "platform"); + const mockedOS = mockGlobalModule(os, { homedir: () => "" }); + const mockedEnvironment = mockGlobalValue(process, "env"); setup(() => { mockFS({}); @@ -34,6 +37,230 @@ suite("SwiftToolchain Unit Test Suite", () => { mockFS.restore(); }); + suite("getXcodeDeveloperDir()", () => { + test("returns the path to the Xcode developer directory using xcrun", async () => { + mockedUtilities.execFile.resolves({ + stderr: "", + stdout: "/path/to/Xcode/developer/dir\r\n\r\n", + }); + await expect(SwiftToolchain.getXcodeDeveloperDir()).to.eventually.equal( + "/path/to/Xcode/developer/dir" + ); + }); + }); + + suite("getSDKPath()", () => { + test("returns the path to the given SDK using xcrun", async () => { + mockedUtilities.execFile.resolves({ + stderr: "", + stdout: "/path/to/macOS/sdk/\r\n\r\n", + }); + await expect(SwiftToolchain.getSDKPath("macOS")).to.eventually.equal( + "/path/to/macOS/sdk/" + ); + }); + }); + + suite("getXcodeInstalls()", () => { + test("returns an array of available Xcode installations on macOS", async () => { + mockedPlatform.setValue("darwin"); + mockedUtilities.execFile.resolves({ + stderr: "", + stdout: "/Applications/Xcode1.app\n/Applications/Xcode2.app\n/Applications/Xcode3.app\n\n\n\n\n", + }); + await expect(SwiftToolchain.getXcodeInstalls()).to.eventually.deep.equal([ + "/Applications/Xcode1.app", + "/Applications/Xcode2.app", + "/Applications/Xcode3.app", + ]); + }); + + test("does nothing on Linux", async () => { + mockedPlatform.setValue("linux"); + await expect(SwiftToolchain.getXcodeInstalls()).to.eventually.be.empty; + expect(mockedUtilities.execFile).to.not.have.been.called; + }); + + test("does nothing on Windows", async () => { + mockedPlatform.setValue("win32"); + await expect(SwiftToolchain.getXcodeInstalls()).to.eventually.be.empty; + expect(mockedUtilities.execFile).to.not.have.been.called; + }); + }); + + suite("getSwiftlyToolchainInstalls()", () => { + test("returns an array of available Swiftly toolchains on Linux if Swiftly is installed", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({ + "/path/to/swiftly/home/config.json": JSON.stringify({ + installedToolchains: ["swift-DEVELOPMENT-6.0.0", "swift-6.0.0", "swift-5.10.1"], + }), + }); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.deep.equal([ + "/path/to/swiftly/home/toolchains/swift-DEVELOPMENT-6.0.0", + "/path/to/swiftly/home/toolchains/swift-6.0.0", + "/path/to/swiftly/home/toolchains/swift-5.10.1", + ]); + }); + + test("does nothing if Swiftly in not installed", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({}); + mockFS({}); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("returns an empty array if no Swiftly configuration is present", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({}); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("returns an empty array if Swiftly configuration is in an unexpected format (installedToolchains is not an array)", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({ + "/path/to/swiftly/home/config.json": JSON.stringify({ + installedToolchains: { + "swift-DEVELOPMENT-6.0.0": "toolchains/swift-DEVELOPMENT-6.0.0", + "swift-6.0.0": "toolchains/swift-6.0.0", + "swift-5.10.1": "toolchains/swift-5.10.1", + }, + }), + }); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("returns an empty array if Swiftly configuration is in an unexpected format (elements of installedToolchains are not strings)", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({ + "/path/to/swiftly/home/config.json": JSON.stringify({ + installedToolchains: [ + { "swift-DEVELOPMENT-6.0.0": "toolchains/swift-DEVELOPMENT-6.0.0" }, + { "swift-6.0.0": "toolchains/swift-6.0.0" }, + { "swift-5.10.1": "toolchains/swift-5.10.1" }, + ], + }), + }); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("returns an empty array if Swiftly configuration is in an unexpected format (installedToolchains does not exist)", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({ + "/path/to/swiftly/home/config.json": JSON.stringify({ + toolchains: ["swift-DEVELOPMENT-6.0.0", "swift-6.0.0", "swift-5.10.1"], + }), + }); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("returns an empty array if Swiftly configuration is corrupt", async () => { + mockedPlatform.setValue("linux"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({ + "/path/to/swiftly/home/config.json": "{", + }); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("does nothing on macOS", async () => { + mockedPlatform.setValue("darwin"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({}); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + + test("does nothing on Windows", async () => { + mockedPlatform.setValue("win32"); + mockedEnvironment.setValue({ + SWIFTLY_HOME_DIR: "/path/to/swiftly/home", + }); + mockFS({}); + await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; + }); + }); + + suite("getToolchainInstalls()", () => { + test("returns an array of available toolchains on macOS", async () => { + mockedPlatform.setValue("darwin"); + mockedOS.homedir.returns("/Users/test/"); + mockFS({ + "/Library/Developer/Toolchains": { + "swift-latest": mockFS.symlink({ path: "swift-6.0.0" }), + "swift-6.0.0": { + usr: { bin: { swift: "" } }, + }, + "swift-5.10.1": { + usr: { bin: { swift: "" } }, + }, + "swift-no-toolchain": {}, + "swift-file": "", + }, + "/Users/test/Library/Developer/Toolchains": { + "swift-latest": mockFS.symlink({ path: "swift-6.0.0" }), + "swift-6.0.0": { + usr: { bin: { swift: "" } }, + }, + "swift-5.10.1": { + usr: { bin: { swift: "" } }, + }, + "swift-no-toolchain": {}, + "swift-file": "", + }, + }); + const actualValue = (await SwiftToolchain.getToolchainInstalls()).sort(); + const expectedValue = [ + "/Library/Developer/Toolchains/swift-latest", + "/Library/Developer/Toolchains/swift-6.0.0", + "/Library/Developer/Toolchains/swift-5.10.1", + "/Users/test/Library/Developer/Toolchains/swift-latest", + "/Users/test/Library/Developer/Toolchains/swift-6.0.0", + "/Users/test/Library/Developer/Toolchains/swift-5.10.1", + ].sort(); + expect(actualValue).to.deep.equal(expectedValue); + }); + + test("returns an empty array if no toolchains are present", async () => { + mockedPlatform.setValue("darwin"); + mockedOS.homedir.returns("/Users/test/"); + mockFS({}); + await expect(SwiftToolchain.getToolchainInstalls()).to.eventually.be.empty; + }); + + test("does nothing on Linux", async () => { + mockedPlatform.setValue("linux"); + mockedOS.homedir.returns("/Users/test/"); + mockFS({}); + await expect(SwiftToolchain.getToolchainInstalls()).to.eventually.be.empty; + }); + + test("does nothing on Windows", async () => { + mockedPlatform.setValue("win32"); + mockedOS.homedir.returns("/Users/test/"); + mockFS({}); + await expect(SwiftToolchain.getToolchainInstalls()).to.eventually.be.empty; + }); + }); + suite("getLLDBDebugAdapter()", () => { function createSwiftToolchain(options: { swiftFolderPath: string; diff --git a/test/unit-tests/ui/ToolchainSelection.test.ts b/test/unit-tests/ui/ToolchainSelection.test.ts new file mode 100644 index 000000000..eb04dd4a0 --- /dev/null +++ b/test/unit-tests/ui/ToolchainSelection.test.ts @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2024 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 * as vscode from "vscode"; +import { mockGlobalModule, mockGlobalObject, mockGlobalValue } from "../../MockUtils"; +import { SwiftToolchain } from "../../../src/toolchain/toolchain"; +import { showToolchainSelectionQuickPick } from "../../../src/ui/ToolchainSelection"; + +suite("ToolchainSelection Unit Test Suite", () => { + const mockPlatform = mockGlobalValue(process, "platform"); + const mockStaticSwiftToolchain = mockGlobalModule(SwiftToolchain); + const mockVSCodeWindow = mockGlobalObject(vscode, "window"); + + test("shows avalable Xcode toolchains on macOS", async () => { + mockPlatform.setValue("darwin"); + mockStaticSwiftToolchain.getXcodeInstalls.resolves([ + "/Applications/Xcode.app", + "/Applications/Xcode-beta.app", + ]); + mockStaticSwiftToolchain.getToolchainInstalls.resolves([]); + mockStaticSwiftToolchain.getSwiftlyToolchainInstalls.resolves([]); + mockVSCodeWindow.showQuickPick.callsFake(async items => { + expect(await items).to.containSubset([ + { + label: "Xcode", + kind: vscode.QuickPickItemKind.Separator, + }, + { + label: "Xcode", + detail: "/Applications/Xcode.app", + }, + { + label: "Xcode-beta", + detail: "/Applications/Xcode-beta.app", + }, + { + label: "actions", + kind: vscode.QuickPickItemKind.Separator, + }, + { + label: "$(cloud-download) Download from Swift.org...", + detail: "Open https://swift.org/install to download and install a toolchain", + }, + { + label: "$(folder-opened) Select toolchain directory...", + detail: "Select a folder on your machine where the Swift toolchain is installed", + }, + ]); + return undefined; + }); + + await showToolchainSelectionQuickPick(); + }); +}); From f9df548a7984ec39e9d4d435a846eae9b02b7c8c Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Wed, 9 Oct 2024 11:26:06 -0400 Subject: [PATCH 3/7] add utility for setting up and tearing down mock-fs --- .../writing-tests-for-vscode-swift.md | 76 +++++++++---------- test/MockUtils.ts | 15 ++++ test/unit-tests/debugger/debugAdapter.test.ts | 9 +-- test/unit-tests/toolchain/toolchain.test.ts | 24 ++---- 4 files changed, 60 insertions(+), 64 deletions(-) diff --git a/docs/contributor/writing-tests-for-vscode-swift.md b/docs/contributor/writing-tests-for-vscode-swift.md index b9012e4f3..4c150d127 100644 --- a/docs/contributor/writing-tests-for-vscode-swift.md +++ b/docs/contributor/writing-tests-for-vscode-swift.md @@ -12,7 +12,6 @@ A brief description of each framework can be found below: - [Organizing Tests](#organizing-tests) - [Writing Unit Tests](#writing-unit-tests) -- [Mocking the File System](#mocking-the-file-system) - [Mocking Utilities](#mocking-utilities) - [Mocking interfaces, classes, and functions](#mocking-interfaces-classes-and-functions) - [Mocking VS Code events](#mocking-vs-code-events) @@ -21,6 +20,7 @@ A brief description of each framework can be found below: - [Mocking global events](#mocking-global-events) - [Setting global constants](#setting-global-constants) - [Mocking an entire module](#mocking-an-entire-module) + - [Mocking the File System](#mocking-the-file-system) - [Conclusion](#conclusion) ## Organizing Tests @@ -114,44 +114,6 @@ suite("ReloadExtension Unit Test Suite", () => { You may have also noticed that we needed to cast the `"Reload Extensions"` string to `any` when resolving `showWarningMessage()`. Unforunately, this may be necessary for methods that have incompatible overloaded signatures due to a TypeScript issue that remains unfixed. -## Mocking the File System - -Mocking file system access can be a challenging endeavor that is prone to fail when implementation details of the unit under test change. This is because there are many different ways of accessing and manipulating files, making it almost impossible to catch all possible failure paths. For example, you could check for file existence using `fs.stat()` or simply call `fs.readFile()` and catch errors with a single function call. Using the real file system is slow and requires extra setup code in test cases to configure. - -The [`mock-fs`](https://github.com/tschaub/mock-fs) module is a well-maintained library that can be used to mitigate these issues by temporarily replacing Node's built-in `fs` module with an in-memory file system. This can be useful for testing logic that uses the `fs` module without actually reaching out to the file system. Just a single function call can be used to configure what the fake file system will contain: - -```typescript -import * as chai from "chai"; -import * as mockFS from "mock-fs"; -import * as fs from "fs/promises"; - -suite("mock-fs example", () => { - // This teardown step is also important to make sure your tests clean up the - // mocked file system when they complete! - teardown(() => { - mockFS.restore(); - }); - - test("mock out a file on disk", async () => { - // A single function call can be used to configure the file system - mockFS({ - "/path/to/some/file": "Some really cool file contents", - }); - await expect(fs.readFile("/path/to/some/file", "utf-8")) - .to.eventually.equal("Some really cool file contents"); - }); -}); -``` - -In order to test failure paths, you can either create an empty file system or use `mockFS.file()` to set the mode to make a file that is not accessible to the current user: - -```typescript -test("file is not readable by the current user", async () => { - mockFS({ "/path/to/file": mockFS.file({ mode: 0o000 }) }); - await expect(fs.readFile("/path/to/file", "utf-8")).to.eventually.be.rejected; -}); -``` - ## Mocking Utilities This section outlines the various utilities that can be used to improve the readability of your tests. The [MockUtils](../../test/MockUtils.ts) module can be used to perform more advanced mocking than what Sinon provides out of the box. This module has its [own set of tests](../../test/unit-tests/MockUtils.test.ts) that you can use to get a feel for how it works. @@ -362,6 +324,42 @@ suite("Mocked configuration example", async function () { }); ``` +#### Mocking the File System + +Mocking file system access can be a challenging endeavor that is prone to fail when implementation details of the unit under test change. This is because there are many different ways of accessing and manipulating files, making it almost impossible to catch all possible failure paths. For example, you could check for file existence using `fs.stat()` or simply call `fs.readFile()` and catch errors with a single function call. Using the real file system is slow and requires extra setup code in test cases to configure. + +The [`mock-fs`](https://github.com/tschaub/mock-fs) module is a well-maintained library that can be used to mitigate these issues by temporarily replacing Node's built-in `fs` module with an in-memory file system. This can be useful for testing logic that uses the `fs` module without actually reaching out to the file system. Just a single function call can be used to configure what the fake file system will contain. + +Our mocking utility provides a function called `mockFileSystem()` that will handle `setup()` and `teardown()` of the `mock-fs` module automatically: + +```typescript +import * as chai from "chai"; +import { mockFileSystem } from "../MockUtils"; +import * as fs from "fs/promises"; + +suite("Mocked file system example", () => { + const mockFS = mockFileSystem(); + + test("mock out a file on disk", async () => { + // A single function call can be used to configure the file system + mockFS({ + "/path/to/some/file": "Some really cool file contents", + }); + await expect(fs.readFile("/path/to/some/file", "utf-8")) + .to.eventually.equal("Some really cool file contents"); + }); +}); +``` + +In order to test failure paths, you can either create an empty file system or use `mockFS.file()` to set the mode to make a file that is not accessible to the current user: + +```typescript +test("file is not readable by the current user", async () => { + mockFS({ "/path/to/file": mockFS.file({ mode: 0o000 }) }); + await expect(fs.readFile("/path/to/file", "utf-8")).to.eventually.be.rejected; +}); +``` + ## Conclusion Writing clear and concise test cases is critical to ensuring the robustness of your contributions. By following the steps outlined in this document, you can create tests that are easy to understand, maintain, and extend. diff --git a/test/MockUtils.ts b/test/MockUtils.ts index ec9ec7e37..85939bdfa 100644 --- a/test/MockUtils.ts +++ b/test/MockUtils.ts @@ -13,6 +13,8 @@ //===----------------------------------------------------------------------===// import * as vscode from "vscode"; import { stub, SinonStub } from "sinon"; +import * as mockFS from "mock-fs"; +import FileSystem = require("mock-fs/lib/filesystem"); /** * Waits for all promises returned by a MockedFunction to resolve. Useful when @@ -495,3 +497,16 @@ export class AsyncEventEmitter { } } } + +/** + * Handles setup() and teardown() of the "mock-fs" module to temporarily mock out + * Node's "fs" module for the duration of a test. + * + * @param config the base configuration that will be set before each test + * @returns the "mock-fs" module for use in test cases + */ +export function mockFileSystem(config: FileSystem.DirectoryItems = {}): typeof mockFS { + setup(() => mockFS(config)); + teardown(() => mockFS.restore()); + return mockFS; +} diff --git a/test/unit-tests/debugger/debugAdapter.test.ts b/test/unit-tests/debugger/debugAdapter.test.ts index b587f6507..abb8e6c67 100644 --- a/test/unit-tests/debugger/debugAdapter.test.ts +++ b/test/unit-tests/debugger/debugAdapter.test.ts @@ -14,8 +14,8 @@ import * as vscode from "vscode"; import { expect } from "chai"; -import * as mockFS from "mock-fs"; import { + mockFileSystem, mockGlobalObject, MockedObject, mockObject, @@ -32,6 +32,7 @@ import { Version } from "../../../src/utilities/version"; import contextKeys from "../../../src/contextKeys"; suite("DebugAdapter Unit Test Suite", () => { + const mockFS = mockFileSystem(); const mockConfiguration = mockGlobalModule(configuration); const mockedContextKeys = mockGlobalModule(contextKeys); const mockedWindow = mockGlobalObject(vscode, "window"); @@ -48,8 +49,6 @@ suite("DebugAdapter Unit Test Suite", () => { customDebugAdapterPath: "", }); mockConfiguration.debugger = instance(mockDebugConfig); - // Mock the file system - mockFS({}); // Mock the WorkspaceContext and related dependencies const toolchainPath = "/toolchains/swift"; mockToolchain = mockObject({ @@ -72,10 +71,6 @@ suite("DebugAdapter Unit Test Suite", () => { }); }); - teardown(() => { - mockFS.restore(); - }); - suite("getLaunchConfigType()", () => { test("returns SWIFT_EXTENSION when Swift version >=6.0.0 and swift.debugger.useDebugAdapterFromToolchain is true", () => { mockDebugConfig.useDebugAdapterFromToolchain = true; diff --git a/test/unit-tests/toolchain/toolchain.test.ts b/test/unit-tests/toolchain/toolchain.test.ts index 1af82a63a..8a8adb12f 100644 --- a/test/unit-tests/toolchain/toolchain.test.ts +++ b/test/unit-tests/toolchain/toolchain.test.ts @@ -13,18 +13,18 @@ //===----------------------------------------------------------------------===// import { expect } from "chai"; -import * as mockFS from "mock-fs"; import * as os from "os"; import * as utilities from "../../../src/utilities/utilities"; import { SwiftToolchain } from "../../../src/toolchain/toolchain"; import { Version } from "../../../src/utilities/version"; -import { mockGlobalModule, mockGlobalValue } from "../../MockUtils"; +import { mockFileSystem, mockGlobalModule, mockGlobalValue } from "../../MockUtils"; suite("SwiftToolchain Unit Test Suite", () => { const mockedUtilities = mockGlobalModule(utilities); const mockedPlatform = mockGlobalValue(process, "platform"); const mockedOS = mockGlobalModule(os, { homedir: () => "" }); const mockedEnvironment = mockGlobalValue(process, "env"); + const mockFS = mockFileSystem(); setup(() => { mockFS({}); @@ -293,10 +293,7 @@ suite("SwiftToolchain Unit Test Suite", () => { test("returns the path to lldb-dap if it exists within a public toolchain", async () => { mockFS({ "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain/usr/bin/lldb-dap": - mockFS.file({ - content: "", - mode: 0o770, - }), + mockFS.file({ mode: 0o770 }), }); const sut = createSwiftToolchain({ swiftFolderPath: @@ -332,10 +329,7 @@ suite("SwiftToolchain Unit Test Suite", () => { }, usr: { bin: { - "lldb-dap": mockFS.file({ - content: "", - mode: 0o770, - }), + "lldb-dap": mockFS.file({ mode: 0o770 }), }, }, }, @@ -389,10 +383,7 @@ suite("SwiftToolchain Unit Test Suite", () => { test("returns the path to lldb-dap if it exists within the toolchain", async () => { mockFS({ "/toolchains/swift-6.0.0/usr/bin": { - "lldb-dap": mockFS.file({ - content: "", - mode: 0o770, - }), + "lldb-dap": mockFS.file({ mode: 0o770 }), }, }); const sut = createSwiftToolchain({ @@ -428,10 +419,7 @@ suite("SwiftToolchain Unit Test Suite", () => { test("returns the path to lldb-dap.exe if it exists within the toolchain", async () => { mockFS({ "/toolchains/swift-6.0.0/usr/bin": { - "lldb-dap.exe": mockFS.file({ - content: "", - mode: 0o770, - }), + "lldb-dap.exe": mockFS.file({ mode: 0o770 }), }, }); const sut = createSwiftToolchain({ From cafe0be0fff7426c1ac322f24ffc1253f10a20a9 Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Wed, 9 Oct 2024 14:39:54 -0400 Subject: [PATCH 4/7] only assert necessary items in the toolchain selection dialog --- test/unit-tests/ui/ToolchainSelection.test.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/test/unit-tests/ui/ToolchainSelection.test.ts b/test/unit-tests/ui/ToolchainSelection.test.ts index eb04dd4a0..29adb0fc9 100644 --- a/test/unit-tests/ui/ToolchainSelection.test.ts +++ b/test/unit-tests/ui/ToolchainSelection.test.ts @@ -23,19 +23,23 @@ suite("ToolchainSelection Unit Test Suite", () => { const mockStaticSwiftToolchain = mockGlobalModule(SwiftToolchain); const mockVSCodeWindow = mockGlobalObject(vscode, "window"); - test("shows avalable Xcode toolchains on macOS", async () => { + test("shows avalable Xcode toolchains sorted by path on macOS", async () => { mockPlatform.setValue("darwin"); mockStaticSwiftToolchain.getXcodeInstalls.resolves([ + "/Applications/OlderXcode.app", "/Applications/Xcode.app", "/Applications/Xcode-beta.app", ]); mockStaticSwiftToolchain.getToolchainInstalls.resolves([]); mockStaticSwiftToolchain.getSwiftlyToolchainInstalls.resolves([]); mockVSCodeWindow.showQuickPick.callsFake(async items => { - expect(await items).to.containSubset([ + const xcodeItems = (await items) + .filter(item => "category" in item && item.category === "xcode") + .map(item => ({ label: item.label, detail: item.detail })); + expect(xcodeItems).to.include.deep.ordered.members([ { - label: "Xcode", - kind: vscode.QuickPickItemKind.Separator, + label: "OlderXcode", + detail: "/Applications/OlderXcode.app", }, { label: "Xcode", @@ -45,18 +49,6 @@ suite("ToolchainSelection Unit Test Suite", () => { label: "Xcode-beta", detail: "/Applications/Xcode-beta.app", }, - { - label: "actions", - kind: vscode.QuickPickItemKind.Separator, - }, - { - label: "$(cloud-download) Download from Swift.org...", - detail: "Open https://swift.org/install to download and install a toolchain", - }, - { - label: "$(folder-opened) Select toolchain directory...", - detail: "Select a folder on your machine where the Swift toolchain is installed", - }, ]); return undefined; }); From 0e87454542ab837091616c55cba3a2cd9b816eec Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Thu, 10 Oct 2024 11:56:26 -0400 Subject: [PATCH 5/7] minor changes to toolchain tests --- test/unit-tests/toolchain/toolchain.test.ts | 178 +++++++++++++++----- 1 file changed, 133 insertions(+), 45 deletions(-) diff --git a/test/unit-tests/toolchain/toolchain.test.ts b/test/unit-tests/toolchain/toolchain.test.ts index 8a8adb12f..d5057aa69 100644 --- a/test/unit-tests/toolchain/toolchain.test.ts +++ b/test/unit-tests/toolchain/toolchain.test.ts @@ -15,28 +15,25 @@ import { expect } from "chai"; import * as os from "os"; import * as utilities from "../../../src/utilities/utilities"; -import { SwiftToolchain } from "../../../src/toolchain/toolchain"; +import { SwiftProjectTemplate, SwiftToolchain } from "../../../src/toolchain/toolchain"; import { Version } from "../../../src/utilities/version"; -import { mockFileSystem, mockGlobalModule, mockGlobalValue } from "../../MockUtils"; +import { mockFileSystem, mockGlobalModule } from "../../MockUtils"; suite("SwiftToolchain Unit Test Suite", () => { const mockedUtilities = mockGlobalModule(utilities); - const mockedPlatform = mockGlobalValue(process, "platform"); + const mockedProcess = mockGlobalModule(process, { + platform: process.platform, + env: process.env, + }); const mockedOS = mockGlobalModule(os, { homedir: () => "" }); - const mockedEnvironment = mockGlobalValue(process, "env"); const mockFS = mockFileSystem(); setup(() => { - mockFS({}); mockedUtilities.execFile.rejects( new Error("execFile was not properly mocked for the test") ); }); - teardown(() => { - mockFS.restore(); - }); - suite("getXcodeDeveloperDir()", () => { test("returns the path to the Xcode developer directory using xcrun", async () => { mockedUtilities.execFile.resolves({ @@ -63,7 +60,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("getXcodeInstalls()", () => { test("returns an array of available Xcode installations on macOS", async () => { - mockedPlatform.setValue("darwin"); + mockedProcess.platform = "darwin"; mockedUtilities.execFile.resolves({ stderr: "", stdout: "/Applications/Xcode1.app\n/Applications/Xcode2.app\n/Applications/Xcode3.app\n\n\n\n\n", @@ -76,13 +73,13 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("does nothing on Linux", async () => { - mockedPlatform.setValue("linux"); + mockedProcess.platform = "linux"; await expect(SwiftToolchain.getXcodeInstalls()).to.eventually.be.empty; expect(mockedUtilities.execFile).to.not.have.been.called; }); test("does nothing on Windows", async () => { - mockedPlatform.setValue("win32"); + mockedProcess.platform = "win32"; await expect(SwiftToolchain.getXcodeInstalls()).to.eventually.be.empty; expect(mockedUtilities.execFile).to.not.have.been.called; }); @@ -90,10 +87,10 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("getSwiftlyToolchainInstalls()", () => { test("returns an array of available Swiftly toolchains on Linux if Swiftly is installed", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({ + mockedProcess.platform = "linux"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({ "/path/to/swiftly/home/config.json": JSON.stringify({ installedToolchains: ["swift-DEVELOPMENT-6.0.0", "swift-6.0.0", "swift-5.10.1"], @@ -107,26 +104,26 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("does nothing if Swiftly in not installed", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({}); + mockedProcess.platform = "linux"; + mockedProcess.env = {}; mockFS({}); await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; }); test("returns an empty array if no Swiftly configuration is present", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({ + mockedProcess.platform = "linux"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({}); await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; }); test("returns an empty array if Swiftly configuration is in an unexpected format (installedToolchains is not an array)", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({ + mockedProcess.platform = "linux"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({ "/path/to/swiftly/home/config.json": JSON.stringify({ installedToolchains: { @@ -140,10 +137,10 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("returns an empty array if Swiftly configuration is in an unexpected format (elements of installedToolchains are not strings)", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({ + mockedProcess.platform = "linux"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({ "/path/to/swiftly/home/config.json": JSON.stringify({ installedToolchains: [ @@ -157,10 +154,10 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("returns an empty array if Swiftly configuration is in an unexpected format (installedToolchains does not exist)", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({ + mockedProcess.platform = "linux"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({ "/path/to/swiftly/home/config.json": JSON.stringify({ toolchains: ["swift-DEVELOPMENT-6.0.0", "swift-6.0.0", "swift-5.10.1"], @@ -170,10 +167,10 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("returns an empty array if Swiftly configuration is corrupt", async () => { - mockedPlatform.setValue("linux"); - mockedEnvironment.setValue({ + mockedProcess.platform = "linux"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({ "/path/to/swiftly/home/config.json": "{", }); @@ -181,19 +178,19 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("does nothing on macOS", async () => { - mockedPlatform.setValue("darwin"); - mockedEnvironment.setValue({ + mockedProcess.platform = "darwin"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({}); await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; }); test("does nothing on Windows", async () => { - mockedPlatform.setValue("win32"); - mockedEnvironment.setValue({ + mockedProcess.platform = "win32"; + mockedProcess.env = { SWIFTLY_HOME_DIR: "/path/to/swiftly/home", - }); + }; mockFS({}); await expect(SwiftToolchain.getSwiftlyToolchainInstalls()).to.eventually.be.empty; }); @@ -201,7 +198,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("getToolchainInstalls()", () => { test("returns an array of available toolchains on macOS", async () => { - mockedPlatform.setValue("darwin"); + mockedProcess.platform = "darwin"; mockedOS.homedir.returns("/Users/test/"); mockFS({ "/Library/Developer/Toolchains": { @@ -240,21 +237,21 @@ suite("SwiftToolchain Unit Test Suite", () => { }); test("returns an empty array if no toolchains are present", async () => { - mockedPlatform.setValue("darwin"); + mockedProcess.platform = "darwin"; mockedOS.homedir.returns("/Users/test/"); mockFS({}); await expect(SwiftToolchain.getToolchainInstalls()).to.eventually.be.empty; }); test("does nothing on Linux", async () => { - mockedPlatform.setValue("linux"); + mockedProcess.platform = "linux"; mockedOS.homedir.returns("/Users/test/"); mockFS({}); await expect(SwiftToolchain.getToolchainInstalls()).to.eventually.be.empty; }); test("does nothing on Windows", async () => { - mockedPlatform.setValue("win32"); + mockedProcess.platform = "win32"; mockedOS.homedir.returns("/Users/test/"); mockFS({}); await expect(SwiftToolchain.getToolchainInstalls()).to.eventually.be.empty; @@ -287,7 +284,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("macOS", () => { setup(() => { - mockedPlatform.setValue("darwin"); + mockedProcess.platform = "darwin"; }); test("returns the path to lldb-dap if it exists within a public toolchain", async () => { @@ -377,7 +374,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("Linux", () => { setup(() => { - mockedPlatform.setValue("linux"); + mockedProcess.platform = "linux"; }); test("returns the path to lldb-dap if it exists within the toolchain", async () => { @@ -413,7 +410,7 @@ suite("SwiftToolchain Unit Test Suite", () => { suite("Windows", () => { setup(() => { - mockedPlatform.setValue("win32"); + mockedProcess.platform = "win32"; }); test("returns the path to lldb-dap.exe if it exists within the toolchain", async () => { @@ -447,4 +444,95 @@ suite("SwiftToolchain Unit Test Suite", () => { }); }); }); + + suite("getProjectTemplates()", () => { + function createSwiftToolchain(swiftVersion: Version): SwiftToolchain { + return new SwiftToolchain( + /* swiftFolderPath */ "/usr/bin", + /* toolchainPath */ "/usr/bin", + /* targetInfo */ { + compilerVersion: swiftVersion.toString(), + paths: { + runtimeLibraryPaths: [], + }, + }, + swiftVersion, + /* runtimePath */ undefined, + /* defaultSDK */ undefined, + /* customSDK */ undefined, + /* xcTestPath */ undefined, + /* swiftTestingPath */ undefined, + /* swiftPMTestingHelperPath */ undefined + ); + } + + test("parses Swift PM output from v6.0.0", async () => { + mockedUtilities.execSwift.resolves({ + stdout: `OVERVIEW: Initialize a new package + +USAGE: swift package init [--type ] [--enable-xctest] [--disable-xctest] [--enable-swift-testing] [--disable-swift-testing] [--name ] + +OPTIONS: + --type Package type: (default: library) + library - A package with a library. + executable - A package with an executable. + tool - A package with an executable that uses + Swift Argument Parser. Use this template if you + plan to have a rich set of command-line arguments. + build-tool-plugin - A package that vends a build tool plugin. + command-plugin - A package that vends a command plugin. + macro - A package that vends a macro. + empty - An empty package with a Package.swift manifest. + --enable-xctest/--disable-xctest + Enable support for XCTest + --enable-swift-testing/--disable-swift-testing + Enable support for Swift Testing + --name Provide custom package name + --version Show the version. + -h, -help, --help Show help information. + +`, + stderr: "", + }); + const sut = createSwiftToolchain(new Version(6, 0, 0)); + + await expect(sut.getProjectTemplates()).to.eventually.include.deep.ordered.members([ + { id: "library", name: "Library", description: "A package with a library." }, + { + id: "executable", + name: "Executable", + description: "A package with an executable.", + }, + { + id: "tool", + name: "Tool", + description: + "A package with an executable that uses Swift Argument Parser. Use this template if you plan to have a rich set of command-line arguments.", + }, + { + id: "build-tool-plugin", + name: "Build Tool Plugin", + description: "A package that vends a build tool plugin.", + }, + { + id: "command-plugin", + name: "Command Plugin", + description: "A package that vends a command plugin.", + }, + { id: "macro", name: "Macro", description: "A package that vends a macro." }, + { + id: "empty", + name: "Empty", + description: "An empty package with a Package.swift manifest.", + }, + ] satisfies SwiftProjectTemplate[]); + }); + + test("returns an empty array on Swift versions prior to 5.8.0", async () => { + mockedUtilities.execSwift.rejects("unknown package command 'init'"); + const sut = createSwiftToolchain(new Version(5, 7, 9)); + + await expect(sut.getProjectTemplates()).to.eventually.be.an("array").that.is.empty; + }); + }); }); From cc41cc679a13d340c099853fcc18c3b6592b499e Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Thu, 10 Oct 2024 16:20:47 -0400 Subject: [PATCH 6/7] add ToolchainSelection tests for Linux --- src/ui/ToolchainSelection.ts | 15 +++ src/utilities/version.ts | 10 ++ test/unit-tests/ui/ToolchainSelection.test.ts | 116 +++++++++++++++--- test/unit-tests/utilities/version.test.ts | 10 ++ 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/ui/ToolchainSelection.ts b/src/ui/ToolchainSelection.ts index 4090021f7..91be8cf2e 100644 --- a/src/ui/ToolchainSelection.ts +++ b/src/ui/ToolchainSelection.ts @@ -17,6 +17,7 @@ import * as path from "path"; import { showReloadExtensionNotification } from "./ReloadExtension"; import { SwiftToolchain } from "../toolchain/toolchain"; import configuration from "../configuration"; +import { Version } from "../utilities/version"; /** * Open the installation page on Swift.org @@ -133,6 +134,18 @@ class SeparatorItem implements vscode.QuickPickItem { /** The possible types of {@link vscode.QuickPickItem} in the toolchain selection dialog */ type SelectToolchainItem = SwiftToolchainItem | ActionItem | SeparatorItem; +function compareToolchainPaths(first: string, second: string): number { + first = path.basename(first, ".xctoolchain"); + const firstVersion = Version.fromString(first) ?? new Version(9999, 9999, 9999); + second = path.basename(second, ".xctoolchain"); + const secondVersion = Version.fromString(second) ?? new Version(9999, 9999, 9999); + const versionComparison = firstVersion.compare(secondVersion); + if (versionComparison === 0) { + return first.localeCompare(second); + } + return versionComparison; +} + /** * Retrieves all {@link SelectToolchainItem} that are available on the system. * @@ -166,6 +179,7 @@ async function getQuickPickItems( }); // Find any public Swift toolchains on the system const toolchains = (await SwiftToolchain.getToolchainInstalls()) + .sort(compareToolchainPaths) .reverse() .map(toolchainPath => { const result: SwiftToolchainItem = { @@ -188,6 +202,7 @@ async function getQuickPickItems( }); // Find any Swift toolchains installed via Swiftly const swiftlyToolchains = (await SwiftToolchain.getSwiftlyToolchainInstalls()) + .sort(compareToolchainPaths) .reverse() .map(toolchainPath => ({ type: "toolchain", diff --git a/src/utilities/version.ts b/src/utilities/version.ts index d9214a82f..de5bec0e5 100644 --- a/src/utilities/version.ts +++ b/src/utilities/version.ts @@ -85,4 +85,14 @@ export class Version implements VersionInterface { isGreaterThanOrEqual(rhs: VersionInterface): boolean { return !this.isLessThan(rhs); } + + compare(rhs: VersionInterface): number { + if (this.isGreaterThan(rhs)) { + return 1; + } else if (this.isLessThan(rhs)) { + return -1; + } else { + return 0; + } + } } diff --git a/test/unit-tests/ui/ToolchainSelection.test.ts b/test/unit-tests/ui/ToolchainSelection.test.ts index 29adb0fc9..ee35e61ad 100644 --- a/test/unit-tests/ui/ToolchainSelection.test.ts +++ b/test/unit-tests/ui/ToolchainSelection.test.ts @@ -32,27 +32,103 @@ suite("ToolchainSelection Unit Test Suite", () => { ]); mockStaticSwiftToolchain.getToolchainInstalls.resolves([]); mockStaticSwiftToolchain.getSwiftlyToolchainInstalls.resolves([]); - mockVSCodeWindow.showQuickPick.callsFake(async items => { - const xcodeItems = (await items) - .filter(item => "category" in item && item.category === "xcode") - .map(item => ({ label: item.label, detail: item.detail })); - expect(xcodeItems).to.include.deep.ordered.members([ - { - label: "OlderXcode", - detail: "/Applications/OlderXcode.app", - }, - { - label: "Xcode", - detail: "/Applications/Xcode.app", - }, - { - label: "Xcode-beta", - detail: "/Applications/Xcode-beta.app", - }, - ]); - return undefined; - }); + mockVSCodeWindow.showQuickPick.resolves(undefined); await showToolchainSelectionQuickPick(); + + expect(mockVSCodeWindow.showQuickPick).to.have.been.calledOnce; + const xcodeItems = (await mockVSCodeWindow.showQuickPick.args[0][0]) + .filter(item => "category" in item && item.category === "xcode") + .map(item => ({ label: item.label, detail: item.detail })); + expect(xcodeItems).to.include.deep.ordered.members([ + { + label: "OlderXcode", + detail: "/Applications/OlderXcode.app", + }, + { + label: "Xcode", + detail: "/Applications/Xcode.app", + }, + { + label: "Xcode-beta", + detail: "/Applications/Xcode-beta.app", + }, + ]); + }); + + test("shows avalable public toolchains sorted in reverse by Swift version on macOS", async () => { + mockPlatform.setValue("darwin"); + mockStaticSwiftToolchain.getXcodeInstalls.resolves([]); + mockStaticSwiftToolchain.getToolchainInstalls.resolves([ + "/Library/Developer/Toolchains/swift-6.0.1-DEVELOPMENT.xctoolchain", + "/Library/Developer/Toolchains/swift-5.10.1-RELEASE.xctoolchain", + "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", + "/Library/Developer/Toolchains/swift-5.9.2-RELEASE.xctoolchain", + "/Library/Developer/swift-latest.xctoolchain", + ]); + mockStaticSwiftToolchain.getSwiftlyToolchainInstalls.resolves([]); + mockVSCodeWindow.showQuickPick.resolves(undefined); + + await showToolchainSelectionQuickPick(); + + expect(mockVSCodeWindow.showQuickPick).to.have.been.calledOnce; + const toolchainItems = (await mockVSCodeWindow.showQuickPick.args[0][0]) + .filter(item => "category" in item && item.category === "public") + .map(item => ({ label: item.label, detail: item.detail })); + expect(toolchainItems).to.include.deep.ordered.members([ + { + label: "Latest Installed Toolchain", + detail: "/Library/Developer/swift-latest.xctoolchain", + }, + { + label: "swift-6.0.1-RELEASE", + detail: "/Library/Developer/Toolchains/swift-6.0.1-RELEASE.xctoolchain", + }, + { + label: "swift-6.0.1-DEVELOPMENT", + detail: "/Library/Developer/Toolchains/swift-6.0.1-DEVELOPMENT.xctoolchain", + }, + { + label: "swift-5.10.1-RELEASE", + detail: "/Library/Developer/Toolchains/swift-5.10.1-RELEASE.xctoolchain", + }, + { + label: "swift-5.9.2-RELEASE", + detail: "/Library/Developer/Toolchains/swift-5.9.2-RELEASE.xctoolchain", + }, + ]); + }); + + test("shows avalable Swiftly toolchains sorted in reverse by Swift version on Linux", async () => { + mockPlatform.setValue("linux"); + mockStaticSwiftToolchain.getXcodeInstalls.resolves([]); + mockStaticSwiftToolchain.getToolchainInstalls.resolves([]); + mockStaticSwiftToolchain.getSwiftlyToolchainInstalls.resolves([ + "/home/user/.swiftly/toolchains/5.10.1", + "/home/user/.swiftly/toolchains/6.0.1", + "/home/user/.swiftly/toolchains/5.9.2", + ]); + mockVSCodeWindow.showQuickPick.resolves(undefined); + + await showToolchainSelectionQuickPick(); + + expect(mockVSCodeWindow.showQuickPick).to.have.been.calledOnce; + const toolchainItems = (await mockVSCodeWindow.showQuickPick.args[0][0]) + .filter(item => "category" in item && item.category === "swiftly") + .map(item => ({ label: item.label, detail: item.detail })); + expect(toolchainItems).to.include.deep.ordered.members([ + { + label: "6.0.1", + detail: "/home/user/.swiftly/toolchains/6.0.1", + }, + { + label: "5.10.1", + detail: "/home/user/.swiftly/toolchains/5.10.1", + }, + { + label: "5.9.2", + detail: "/home/user/.swiftly/toolchains/5.9.2", + }, + ]); }); }); diff --git a/test/unit-tests/utilities/version.test.ts b/test/unit-tests/utilities/version.test.ts index 3de7f5a27..67c27f6b3 100644 --- a/test/unit-tests/utilities/version.test.ts +++ b/test/unit-tests/utilities/version.test.ts @@ -111,4 +111,14 @@ suite("Version Suite", () => { expect(new Version(5, 10, 1).isGreaterThanOrEqual(new Version(5, 10, 0))).to.be.true; expect(new Version(6, 0, 0).isGreaterThanOrEqual(new Version(5, 10, 1))).to.be.true; }); + + test("compare", () => { + expect(new Version(5, 10, 1).compare(new Version(6, 0, 0))).to.equal(-1); + expect(new Version(5, 9, 0).compare(new Version(5, 10, 0))).to.equal(-1); + expect(new Version(5, 10, 0).compare(new Version(5, 10, 1))).to.equal(-1); + expect(new Version(5, 10, 1).compare(new Version(5, 10, 1))).to.equal(0); + expect(new Version(5, 10, 0).compare(new Version(5, 9, 0))).to.equal(1); + expect(new Version(5, 10, 1).compare(new Version(5, 10, 0))).to.equal(1); + expect(new Version(6, 0, 0).compare(new Version(5, 10, 1))).to.equal(1); + }); }); From 8901e21e3d7bc6584f6e236f07cd6f2520c773e0 Mon Sep 17 00:00:00 2001 From: Matthew Bastien Date: Thu, 10 Oct 2024 16:28:38 -0400 Subject: [PATCH 7/7] only restore module properties that were actually mocked --- test/MockUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/MockUtils.ts b/test/MockUtils.ts index 85939bdfa..04d4185a6 100644 --- a/test/MockUtils.ts +++ b/test/MockUtils.ts @@ -326,7 +326,7 @@ export function mockGlobalModule(module: T, overrides?: Partial): MockedOb }); // Restore original value at teardown teardown(() => { - for (const property of Object.getOwnPropertyNames(originalValue)) { + for (const property of Object.getOwnPropertyNames(realMock)) { try { Object.defineProperty(module, property, { value: (originalValue as any)[property],