Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Fix the wrong toolchain being shown as selected when using swiftly v1.0.1 ([#2014](https://github.com/swiftlang/vscode-swift/pull/2014))
- Fix extension displaying SwiftPM's project view and automatic build tasks even when `disableSwiftPMIntegration` was true ([#2011](https://github.com/swiftlang/vscode-swift/pull/2011))
- Validate extension settings and warn if they are invalid ([#2016](https://github.com/swiftlang/vscode-swift/pull/2016))

## 2.14.3 - 2025-12-15

Expand Down
554 changes: 403 additions & 151 deletions src/configuration.ts

Large diffs are not rendered by default.

25 changes: 20 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import { FolderEvent, FolderOperation, WorkspaceContext } from "./WorkspaceConte
import * as commands from "./commands";
import { resolveFolderDependencies } from "./commands/dependencies/resolve";
import { registerSourceKitSchemaWatcher } from "./commands/generateSourcekitConfiguration";
import configuration, { handleConfigurationChangeEvent } from "./configuration";
import configuration, {
ConfigurationValidationError,
handleConfigurationChangeEvent,
openSettingsJsonForSetting,
} from "./configuration";
import { ContextKeys, createContextKeys } from "./contextKeys";
import { registerDebugger } from "./debugger/debugAdapterFactory";
import * as debug from "./debugger/launch";
Expand Down Expand Up @@ -190,10 +194,21 @@ export async function activate(context: vscode.ExtensionContext): Promise<Api> {
},
};
} catch (error) {
const errorMessage = getErrorDescription(error);
// show this error message as the VS Code error message only shows when running
// the extension through the debugger
void vscode.window.showErrorMessage(`Activating Swift extension failed: ${errorMessage}`);
// Handle configuration validation errors with UI that points the user to the poorly configured setting
if (error instanceof ConfigurationValidationError) {
void vscode.window.showErrorMessage(error.message, "Open Settings").then(selection => {
if (selection === "Open Settings") {
void openSettingsJsonForSetting(error.settingName);
}
});
} else {
const errorMessage = getErrorDescription(error);
// show this error message as the VS Code error message only shows when running
// the extension through the debugger
void vscode.window.showErrorMessage(
`Activating Swift extension failed: ${errorMessage}`
);
}
throw error;
}
}
Expand Down
75 changes: 58 additions & 17 deletions test/MockUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,15 +242,25 @@ export function mockGlobalObject<T, K extends MockableObjectsOf<T>>(
property: K
): MockedObject<T[K]> {
let realMock: MockedObject<T[K]>;
let originalDescriptor: PropertyDescriptor | undefined;
const originalValue: T[K] = obj[property];
// Create the mock at setup
setup(() => {
originalDescriptor = Object.getOwnPropertyDescriptor(obj, property);
realMock = mockObject(obj[property]);
Object.defineProperty(obj, property, { value: realMock });
Object.defineProperty(obj, property, {
value: realMock,
writable: true,
configurable: true,
});
});
// Restore original value at teardown
// Restore original property descriptor at teardown
teardown(() => {
Object.defineProperty(obj, property, { value: originalValue });
if (originalDescriptor) {
Object.defineProperty(obj, property, originalDescriptor);
} else {
delete (obj as any)[property];
}
});
// Return the proxy to the real mock
return new Proxy<any>(originalValue, {
Expand Down Expand Up @@ -301,32 +311,46 @@ function shallowClone<T>(obj: T): T {
*/
export function mockGlobalModule<T>(mod: T): MockedObject<T> {
let realMock: MockedObject<T>;
const originalDescriptors = new Map<string | symbol, PropertyDescriptor>();
const originalValue: T = shallowClone(mod);
// Create the mock at setup
setup(() => {
realMock = mockObject(mod);
for (const property of Object.getOwnPropertyNames(realMock)) {
try {
const originalDescriptor = Object.getOwnPropertyDescriptor(mod, property);
if (originalDescriptor) {
originalDescriptors.set(property, originalDescriptor);
}
Object.defineProperty(mod, property, {
value: (realMock as any)[property],
writable: true,
configurable: true,
});
} catch {
// Some properties of a module just can't be mocked and that's fine
}
}
});
// Restore original value at teardown
// Restore original property descriptors at teardown
teardown(() => {
for (const property of Object.getOwnPropertyNames(originalValue)) {
try {
Object.defineProperty(mod, property, {
value: (originalValue as any)[property],
});
const originalDescriptor = originalDescriptors.get(property);
if (originalDescriptor) {
Object.defineProperty(mod, property, originalDescriptor);
} else {
Object.defineProperty(mod, property, {
value: (originalValue as any)[property],
writable: true,
configurable: true,
});
}
} catch {
// Some properties of a module just can't be mocked and that's fine
}
}
originalDescriptors.clear();
});
// Return the proxy to the real mock
return new Proxy<any>(originalValue, {
Expand Down Expand Up @@ -374,15 +398,19 @@ export interface MockedValue<T> {
*/
export function mockGlobalValue<T, K extends keyof T>(obj: T, property: K): MockedValue<T[K]> {
let setupComplete: boolean = false;
let originalValue: T[K];
// Grab the original value during setup
let originalDescriptor: PropertyDescriptor | undefined;
// Grab the original property descriptor during setup
setup(() => {
originalValue = obj[property];
originalDescriptor = Object.getOwnPropertyDescriptor(obj, property);
setupComplete = true;
});
// Restore the original value on teardown
// Restore the original property descriptor on teardown
teardown(() => {
Object.defineProperty(obj, property, { value: originalValue });
if (originalDescriptor) {
Object.defineProperty(obj, property, originalDescriptor);
} else {
delete (obj as any)[property];
}
setupComplete = false;
});
// Return a ValueMock that allows for easy mocking of the value
Expand All @@ -391,7 +419,11 @@ export function mockGlobalValue<T, K extends keyof T>(obj: T, property: K): Mock
if (!setupComplete) {
throw new Error("Mocks cannot be accessed outside of test functions");
}
Object.defineProperty(obj, property, { value: value });
Object.defineProperty(obj, property, {
value: value,
writable: true,
configurable: true,
});
},
};
}
Expand Down Expand Up @@ -441,15 +473,24 @@ export function mockGlobalEvent<T, K extends EventsOf<T>>(
property: K
): AsyncEventEmitter<EventType<T[K]>> {
let eventEmitter: vscode.EventEmitter<EventType<T[K]>>;
const originalValue: T[K] = obj[property];
let originalDescriptor: PropertyDescriptor | undefined;
// Create the mock at setup
setup(() => {
originalDescriptor = Object.getOwnPropertyDescriptor(obj, property);
eventEmitter = new vscode.EventEmitter();
Object.defineProperty(obj, property, { value: eventEmitter.event });
Object.defineProperty(obj, property, {
value: eventEmitter.event,
writable: true,
configurable: true,
});
});
// Restore original value at teardown
// Restore original property descriptor at teardown
teardown(() => {
Object.defineProperty(obj, property, { value: originalValue });
if (originalDescriptor) {
Object.defineProperty(obj, property, originalDescriptor);
} else {
delete (obj as any)[property];
}
});
// Return the proxy to the EventEmitter
return new Proxy(new AsyncEventEmitter(), {
Expand Down
6 changes: 6 additions & 0 deletions test/unit-tests/debugger/debugAdapterFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ suite("LLDBDebugConfigurationProvider Tests", () => {
get: mockFn(s => {
s.withArgs("library").returns("/path/to/liblldb.dyLib");
s.withArgs("launch.expressions").returns("native");
// Add defaults for swift configuration properties
s.withArgs("path").returns("");
s.withArgs("runtimePath").returns("");
s.withArgs("swiftEnvironmentVariables").returns({});
// Default fallback
s.returns(undefined);
}),
update: mockFn(),
});
Expand Down
9 changes: 8 additions & 1 deletion test/unit-tests/ui/ToolchainSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ suite("ToolchainSelection Unit Test Suite", () => {
mockedConfiguration = mockObject<vscode.WorkspaceConfiguration>({
update: mockFn(),
inspect: mockFn(s => s.returns({})),
get: mockFn(),
get: mockFn(s => {
// Return appropriate defaults for configuration properties
s.withArgs("path", match.any).returns("");
s.withArgs("runtimePath", match.any).returns("");
s.withArgs("swiftEnvironmentVariables", match.any).returns({});
// Default fallback
s.returns(undefined);
}),
has: mockFn(s => s.returns(false)),
});
mockedVSCodeWorkspace.getConfiguration.returns(instance(mockedConfiguration));
Expand Down
88 changes: 88 additions & 0 deletions test/unit-tests/utilities/configuration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the VS Code Swift open source project
//
// Copyright (c) 2025 the VS Code Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import * as assert from "assert";
import { setup } from "mocha";
import { match } from "sinon";
import * as vscode from "vscode";

import configuration from "@src/configuration";

import { instance, mockFn, mockGlobalObject, mockObject } from "../../MockUtils";

suite("Configuration/Settings Test Suite", () => {
suite("Type validation", () => {
const mockWorkspace = mockGlobalObject(vscode, "workspace");

setup(() => {
mockWorkspace.getConfiguration.reset();
});

function mockSetting<T>(settingName: string, value: T) {
const [, ...rest] = settingName.split(".");
const mockSwiftConfig = mockObject<vscode.WorkspaceConfiguration>({
get: mockFn(s => s.withArgs(rest.join("."), match.any).returns(value)),
});
mockWorkspace.getConfiguration.returns(instance(mockSwiftConfig));
}

test("returns a string configuration value", () => {
mockSetting("swift.path", "foo");
assert.equal(configuration.path, "foo");
});

test("throws when a string setting is not a string", () => {
mockSetting("swift.path", 42);
assert.throws(() => {
configuration.path;
});
});

test("returns a boolean configuration value", () => {
mockSetting("swift.recordTestDuration", false);
assert.equal(configuration.recordTestDuration, false);
});

test("throws when a boolean setting is not a boolean", () => {
mockSetting("swift.recordTestDuration", "notaboolean");
assert.throws(() => {
configuration.recordTestDuration;
});
});

test("returns a string array configuration value", () => {
mockSetting("swift.excludeFromCodeCoverage", ["foo", "bar"]);
assert.deepEqual(configuration.excludeFromCodeCoverage, ["foo", "bar"]);
});

test("throws when a string array setting is not a string array", () => {
mockSetting("swift.excludeFromCodeCoverage", [42, true]);
assert.throws(() => {
configuration.excludeFromCodeCoverage;
});
});

test("returns an object configuration value", () => {
const obj = { FOO: "BAR" };
mockSetting("swift.swiftEnvironmentVariables", obj);
assert.deepEqual(configuration.swiftEnvironmentVariables, obj);
});

test("throws when an object setting is not an object", () => {
mockSetting("swift.swiftEnvironmentVariables", "notanobject");
assert.throws(() => {
configuration.swiftEnvironmentVariables;
});
});
});
});