diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9daae17..d2fa068 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,20 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio 2. Make your changes and ensure they pass the existing tests and linting. 3. Submit a pull request with a clear description of the changes. +### Settings boundary tests + +The JSON corpus in `python/tests/fixtures/settings/` characterizes the existing +initialization-options contract. Fixture metadata is separate from +`initializationOptions`; `__ROOT_URI__`, `__ROOT_FS__`, `__CWD_FS__`, `__PYTHON__` +and `__SEP__` are test-only substitutions, not supported settings variables. +Python and TypeScript tests independently consume `producer.json`, including +host-resolved CWD variables that Python finishes resolving per document. + +`python/tests/test_server_stdio.py` starts a test-only ToolServer over real LSP +stdio and checks initialize/initialized/settings/shutdown/exit. It needs no tool +installation, VS Code host or Node build. Repeated settings application is +covered separately in-process, not by reinitializing a live LSP session. + ## Versioning This project adheres to [Semantic Versioning (SemVer)](https://semver.org/). diff --git a/python/tests/fixtures/settings/legacy-partial.json b/python/tests/fixtures/settings/legacy-partial.json new file mode 100644 index 0000000..2ad70b3 --- /dev/null +++ b/python/tests/fixtures/settings/legacy-partial.json @@ -0,0 +1,29 @@ +{ + "description": "Partial client payload: explicit empty arrays, shallow defaults, unknown fields and hardcoded global defaults.", + "toolDefaults": { + "severity": { "E": "Error", "W": "Warning" } + }, + "hardcodedSettings": { + "ignorePatterns": [] + }, + "initializationOptions": { + "globalSettings": { + "args": ["--global"], + "interpreter": ["__PYTHON__"], + "importStrategy": "fromEnvironment", + "severity": { "E": "Warning" }, + "ignorePatterns": ["global/**"], + "unknownGlobal": { "keep": true } + }, + "settings": [ + { + "workspace": "__ROOT_URI__", + "cwd": "__ROOT_FS__", + "args": [], + "path": [], + "interpreter": [], + "extensionOption": { "enabled": false } + } + ] + } +} diff --git a/python/tests/fixtures/settings/producer.json b/python/tests/fixtures/settings/producer.json new file mode 100644 index 0000000..108e27c --- /dev/null +++ b/python/tests/fixtures/settings/producer.json @@ -0,0 +1,54 @@ +{ + "description": "Complete producer payload, with host variables resolved and document CWD variables deferred.", + "toolDefaults": { + "severity": { "E": "Error", "W": "Warning" }, + "extensionOption": { "enabled": true, "label": "default" } + }, + "configuration": { + "workspace": { + "cwd": "${workspaceFolder}__SEP__${relativeFileDirname}", + "args": [ + "--label", + "café", + "--config", + "${workspaceFolder}__SEP__tool.toml" + ], + "path": [], + "interpreter": ["__PYTHON__"], + "severity": { "E": "Hint" }, + "extensionOption": { "enabled": false } + }, + "global": { + "args": ["--global"], + "interpreter": ["__PYTHON__"], + "severity": { "E": "Warning" }, + "extensionOption": { "enabled": true, "label": "global" } + } + }, + "initializationOptions": { + "settings": [ + { + "cwd": "__ROOT_FS____SEP__${relativeFileDirname}", + "workspace": "__ROOT_URI__", + "args": ["--label", "café", "--config", "__ROOT_FS____SEP__tool.toml"], + "path": [], + "interpreter": ["__PYTHON__"], + "importStrategy": "useBundled", + "showNotifications": "off", + "severity": { "E": "Hint" }, + "extensionOption": { "enabled": false } + } + ], + "globalSettings": { + "cwd": "__CWD_FS__", + "workspace": "__CWD_FS__", + "args": ["--global"], + "path": [], + "interpreter": ["__PYTHON__"], + "importStrategy": "fromEnvironment", + "showNotifications": "off", + "severity": { "E": "Warning" }, + "extensionOption": { "enabled": true, "label": "global" } + } + } +} diff --git a/python/tests/fixtures/settings_server.py b/python/tests/fixtures/settings_server.py new file mode 100644 index 0000000..be7fded --- /dev/null +++ b/python/tests/fixtures/settings_server.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Test-only public LSP server; never invokes a formatter or linter.""" + +from __future__ import annotations + +import json +import pathlib +import sys +from typing import Any + +import lsprotocol.types as lsp + +import vscode_common_python_lsp.server as server_module +from vscode_common_python_lsp.server import ToolServer, ToolServerConfig + + +def main() -> None: + fixture_path = pathlib.Path(__file__).parent / "settings" / "producer.json" + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + tool = ToolServer( + ToolServerConfig( + tool_module="settings-fixture", + tool_display="Settings Fixture", + default_settings=fixture["toolDefaults"], + ) + ) + initialized_received = False + + @tool.server.feature(lsp.INITIALIZE) + def initialize(params: lsp.InitializeParams) -> None: + tool.apply_settings(params) + tool.log_to_output("fixture initialize applied") + + @tool.server.feature(lsp.INITIALIZED) + def initialized(_params: lsp.InitializedParams) -> None: + nonlocal initialized_received + initialized_received = True + tool.log_to_output("fixture initialized") + + @tool.server.feature("test/settings") + def inspect_settings(_params: Any) -> dict[str, Any]: + return { + "settings": tool.workspace_settings, + "globalSettings": tool.global_settings, + "initialized": initialized_received, + "serverModule": server_module.__file__, + } + + @tool.server.feature(lsp.SHUTDOWN) + def shutdown(_params: Any) -> None: + tool.handle_shutdown() + print("fixture shutdown complete", file=sys.stderr, flush=True) + + @tool.server.feature(lsp.EXIT) + def exit_server(_params: Any) -> None: + tool.handle_exit() + print("fixture exit complete", file=sys.stderr, flush=True) + + tool.server.start_io() + + +if __name__ == "__main__": + main() diff --git a/python/tests/settings_fixtures.py b/python/tests/settings_fixtures.py new file mode 100644 index 0000000..3660a94 --- /dev/null +++ b/python/tests/settings_fixtures.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Load the on-disk settings contract shared with the TypeScript tests.""" + +from __future__ import annotations + +import json +import os +import pathlib +import sys +from typing import Any + + +def load_settings_fixture(name: str, root: pathlib.Path) -> dict[str, Any]: + """Expand test-only path placeholders without changing payload values.""" + replacements = { + "__ROOT_URI__": root.as_uri(), + "__ROOT_FS__": str(root), + "__CWD_FS__": os.getcwd(), + "__PYTHON__": sys.executable, + "__SEP__": os.sep, + } + + def expand(value: Any) -> Any: + if isinstance(value, str): + for token, replacement in replacements.items(): + value = value.replace(token, replacement) + return value + if isinstance(value, list): + return [expand(item) for item in value] + if isinstance(value, dict): + return {key: expand(item) for key, item in value.items()} + return value + + fixture_path = ( + pathlib.Path(__file__).parent / "fixtures" / "settings" / f"{name}.json" + ) + return expand(json.loads(fixture_path.read_text(encoding="utf-8"))) diff --git a/python/tests/test_server.py b/python/tests/test_server.py index 429be8c..4964ee1 100644 --- a/python/tests/test_server.py +++ b/python/tests/test_server.py @@ -11,10 +11,15 @@ import unittest from unittest.mock import MagicMock, patch +import lsprotocol.types as lsp import pytest +from pygls import uris +from pygls.workspace import TextDocument from vscode_common_python_lsp.server import ToolServer, ToolServerConfig +from .settings_fixtures import load_settings_fixture + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -950,6 +955,286 @@ def test_get_cwd_different_drives_no_crash(self): assert isinstance(result, str) +# --------------------------------------------------------------------------- +# Settings boundary characterization +# --------------------------------------------------------------------------- + + +class TestSettingsContract: + def test_shared_producer_payload_and_staged_cwd(self, tmp_path): + root = tmp_path / "workspace café" + root.mkdir() + fixture = load_settings_fixture("producer", root) + options = fixture["initializationOptions"] + ts = _make_server( + ToolServerConfig( + tool_module="testtool", + tool_display="Test Tool", + default_settings=fixture["toolDefaults"], + ) + ) + + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + initialization_options=options, + ) + ) + + key = str(pathlib.Path(uris.to_fs_path(root.as_uri()))) + assert ts.global_settings == options["globalSettings"] + assert ts.workspace_settings == { + key: {**options["settings"][0], "workspaceFS": key} + } + document = TextDocument((root / "src" / "demo.py").as_uri()) + settings = ts.get_settings_by_document(document) + assert settings is ts.workspace_settings[key] + assert ts.get_cwd(settings, document) == str(root / "src") + assert ts.get_cwd(settings) == key + + @pytest.mark.parametrize( + "overrides,severity,ignore_patterns", + [ + ({}, {"E": "Warning"}, []), + ( + {"severity": {"W": "Hint"}, "ignorePatterns": ["workspace/**"]}, + {"W": "Hint"}, + ["workspace/**"], + ), + ], + ids=["global-defaults", "explicit-workspace-overrides"], + ) + def test_partial_payload_projection_and_shallow_precedence( + self, tmp_path, overrides, severity, ignore_patterns + ): + fixture = load_settings_fixture("legacy-partial", tmp_path) + options = fixture["initializationOptions"] + options["settings"][0].update(overrides) + ts = _make_server( + ToolServerConfig( + tool_module="testtool", + tool_display="Test Tool", + default_settings=fixture["toolDefaults"], + hardcoded_settings=fixture["hardcodedSettings"], + ) + ) + + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + initialization_options=options, + ) + ) + + key = str(pathlib.Path(uris.to_fs_path(tmp_path.as_uri()))) + assert ts.global_settings == options["globalSettings"] + assert ts.global_settings["unknownGlobal"] == {"keep": True} + assert ts.workspace_settings == { + key: { + "workspace": tmp_path.as_uri(), + "workspaceFS": key, + "cwd": str(tmp_path), + "args": [], + "path": [], + "interpreter": [], + "importStrategy": "fromEnvironment", + "showNotifications": "off", + "severity": severity, + "ignorePatterns": ignore_patterns, + "extensionOption": {"enabled": False}, + } + } + + @pytest.mark.parametrize("value", ["empty", "null"]) + def test_explicit_values_do_not_regain_defaults(self, tmp_path, value): + fixture = load_settings_fixture("legacy-partial", tmp_path) + options = fixture["initializationOptions"] + options["globalSettings"].update( + {"path": ["global-tool"], "enabled": True, "showNotifications": "always"} + ) + overrides = { + "args": [], + "path": [], + "interpreter": [], + "cwd": "", + "importStrategy": "", + "showNotifications": "", + "enabled": False, + "severity": {}, + "extensionOption": None, + } + if value == "null": + overrides = dict.fromkeys(overrides) + options["settings"][0].update(overrides) + ts = _make_server( + ToolServerConfig( + tool_module="testtool", + tool_display="Test Tool", + default_settings={**fixture["toolDefaults"], "enabled": True}, + ) + ) + + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + initialization_options=options, + ) + ) + + settings = next(iter(ts.workspace_settings.values())) + assert {key: settings[key] for key in overrides} == overrides + if value == "null": + with pytest.raises(TypeError): + ts.get_cwd(settings) + else: + assert ts.get_cwd(settings) == "" + + @pytest.mark.parametrize( + "options", + [ + None, + {}, + {"globalSettings": {"args": ["--global"]}}, + {"globalSettings": {"args": ["--global"]}, "settings": None}, + {"globalSettings": {"args": ["--global"]}, "settings": []}, + ], + ids=[ + "no-options", + "empty-options", + "missing-settings", + "null-settings", + "empty-settings", + ], + ) + def test_absent_settings_use_server_cwd_not_client_root( + self, tmp_path, monkeypatch, options + ): + cwd = tmp_path / "server cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + ts = _make_server() + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + root_uri=(tmp_path / "client root").as_uri(), + initialization_options=options, + ) + ) + + key = os.getcwd() + globals_ = (options or {}).get("globalSettings", {}) + assert ts.global_settings == globals_ + assert ts.workspace_settings == { + key: { + "cwd": key, + "workspaceFS": key, + "workspace": uris.from_fs_path(key), + "path": [], + "interpreter": [sys.executable], + "args": globals_.get("args", []), + "importStrategy": "useBundled", + "showNotifications": "off", + } + } + + def test_null_global_settings_are_rejected(self): + ts = _make_server() + with pytest.raises(TypeError): + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + initialization_options={"globalSettings": None}, + ) + ) + + def test_repeated_application_retains_aliases_and_routes_real_uris(self, tmp_path): + ts = _make_server() + workspaces = ts.workspace_settings + globals_ = ts.global_settings + root = tmp_path / "root" + nested = root / "nested" + other = tmp_path / "other" + for directory in (root, nested, other): + directory.mkdir(parents=True, exist_ok=True) + + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + initialization_options={ + "globalSettings": { + "args": ["--first"], + "unknownGlobal": {"keep": True}, + }, + "settings": [ + { + "workspace": root.as_uri(), + "args": ["--root"], + "extensionOption": {"old": True}, + }, + {"workspace": other.as_uri(), "args": ["--retained"]}, + ], + }, + ) + ) + ts.apply_settings( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + process_id=None, + initialization_options={ + "globalSettings": {"args": ["--second"], "path": []}, + "settings": [ + {"workspace": root.as_uri(), "args": []}, + {"workspace": nested.as_uri(), "interpreter": []}, + {"workspace": "pico:", "args": ["--virtual"]}, + ], + }, + ) + ) + + assert ts.workspace_settings is workspaces + assert ts.global_settings is globals_ + assert globals_ == { + "args": ["--second"], + "path": [], + "unknownGlobal": {"keep": True}, + } + keys = [ + str(pathlib.Path(uris.to_fs_path(p.as_uri()))) + for p in (root, nested, other) + ] + assert set(workspaces) == set(keys) + assert [workspaces[key]["args"] for key in keys] == [ + [], + ["--second"], + ["--retained"], + ] + assert workspaces[keys[1]]["interpreter"] == [] + assert "extensionOption" not in workspaces[keys[0]] + for directory, key in zip((root, nested, other), keys): + document = TextDocument((directory / "src" / "demo.py").as_uri()) + assert ts.get_document_key(document) == key + assert ts.get_settings_by_document(document) is workspaces[key] + assert ( + ts.get_settings_by_path(pathlib.Path(document.path)) is workspaces[key] + ) + + outside = TextDocument((tmp_path / "outside" / "demo.py").as_uri()) + assert ( + ts.get_settings_by_path(pathlib.Path(outside.path)) is workspaces[keys[0]] + ) + fallback = ts.get_settings_by_document(outside) + assert fallback["workspaceFS"] == str(pathlib.Path(outside.path).parent) + assert fallback["args"] == ["--second"] + assert "unknownGlobal" not in fallback + assert ts.get_settings_by_document(None) is workspaces[keys[0]] + + # --------------------------------------------------------------------------- # Constructor # --------------------------------------------------------------------------- diff --git a/python/tests/test_server_stdio.py b/python/tests/test_server_stdio.py new file mode 100644 index 0000000..397547b --- /dev/null +++ b/python/tests/test_server_stdio.py @@ -0,0 +1,196 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Exercise the real ToolServer/pygls lifecycle over public LSP stdio.""" + +from __future__ import annotations + +import contextlib +import json +import os +import pathlib +import queue +import subprocess +import sys +import threading +import time +from typing import Any + +from pygls import uris + +import vscode_common_python_lsp.server as server_module + +from .settings_fixtures import load_settings_fixture + +PYTHON_ROOT = pathlib.Path(__file__).resolve().parents[1] +SERVER_FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "settings_server.py" +TIMEOUT = 20 + + +class _LspProcess: + def __init__(self, cwd: pathlib.Path): + assert pathlib.Path(server_module.__file__).resolve() == ( + PYTHON_ROOT / "vscode_common_python_lsp" / "server.py" + ), "Bind PYTHONPATH to this checkout before running the tests" + env = { + **os.environ, + "PYTHONPATH": str(PYTHON_ROOT), + "PYTHONIOENCODING": "utf-8", + } + self.process = subprocess.Popen( + [sys.executable, "-u", str(SERVER_FIXTURE)], + cwd=cwd, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.messages: queue.Queue[dict[str, Any] | Exception | None] = queue.Queue() + self.notifications: list[dict[str, Any]] = [] + self.stderr_chunks: list[bytes] = [] + self.stdout_thread = threading.Thread(target=self._read_stdout, daemon=True) + self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + self.stdout_thread.start() + self.stderr_thread.start() + + @property + def stderr(self) -> str: + return b"".join(self.stderr_chunks).decode("utf-8", errors="replace") + + def _read_stdout(self) -> None: + try: + while line := self.process.stdout.readline(): + headers = {} + while line != b"\r\n": + assert line.endswith(b"\r\n"), f"Non-LSP stdout: {line!r}" + name, separator, value = line[:-2].partition(b":") + assert separator, f"Non-LSP stdout header: {line!r}" + headers[name.lower()] = value.strip() + line = self.process.stdout.readline() + assert line, "EOF in LSP headers" + length = int(headers[b"content-length"]) + assert length > 0, f"Invalid Content-Length: {length}" + body = self.process.stdout.read(length) + assert len(body) == length, "EOF in LSP body" + message = json.loads(body.decode("utf-8")) + assert isinstance(message, dict) and message.get("jsonrpc") == "2.0" + self.messages.put(message) + except Exception as exc: # Report reader failures on the test thread. + self.messages.put(exc) + finally: + self.messages.put(None) + + def _read_stderr(self) -> None: + while chunk := self.process.stderr.read1(4096): + self.stderr_chunks.append(chunk) + + def send(self, message: dict[str, Any]) -> None: + body = json.dumps(message, ensure_ascii=False).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + self.process.stdin.write(header + body) + self.process.stdin.flush() + + def response(self, request_id: int) -> Any: + deadline = time.monotonic() + TIMEOUT + while True: + remaining = deadline - time.monotonic() + assert ( + remaining > 0 + ), f"Timed out awaiting response {request_id}\n{self.stderr}" + try: + message = self.messages.get(timeout=remaining) + except queue.Empty as exc: + raise AssertionError( + f"Timed out awaiting response {request_id}\n{self.stderr}" + ) from exc + assert message is not None, f"Server stdout closed early\n{self.stderr}" + if isinstance(message, Exception): + raise AssertionError( + f"Invalid server stdout: {message}\n{self.stderr}" + ) from message + if "id" not in message: + assert "method" in message, message + self.notifications.append(message) + continue + assert message["id"] == request_id, message + assert "error" not in message, message + return message["result"] + + def close(self) -> None: + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=5) + with contextlib.suppress(BrokenPipeError): + self.process.stdin.close() + self.stdout_thread.join(timeout=5) + self.stderr_thread.join(timeout=5) + assert not self.stdout_thread.is_alive(), "stdout reader did not finish" + assert not self.stderr_thread.is_alive(), "stderr reader did not finish" + self.process.stdout.close() + self.process.stderr.close() + + def assert_protocol_complete(self) -> None: + eof_received = False + while not self.messages.empty(): + message = self.messages.get_nowait() + if message is None: + eof_received = True + else: + assert isinstance(message, dict), f"Invalid trailing stdout: {message}" + assert "method" in message and "id" not in message, message + self.notifications.append(message) + assert eof_received, "stdout reader did not reach EOF" + + +def test_real_stdio_settings_and_lifecycle(tmp_path): + root = tmp_path / "workspace café" + root.mkdir() + options = load_settings_fixture("producer", root)["initializationOptions"] + client = _LspProcess(tmp_path) + try: + client.send( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": None, + "rootUri": root.as_uri(), + "capabilities": {}, + "initializationOptions": options, + }, + } + ) + result = client.response(1) + assert isinstance(result["capabilities"], dict) + client.send({"jsonrpc": "2.0", "method": "initialized", "params": {}}) + client.send( + {"jsonrpc": "2.0", "id": 2, "method": "test/settings", "params": {}} + ) + inspection = client.response(2) + assert inspection["initialized"] is True + assert ( + pathlib.Path(inspection["serverModule"]).resolve() + == pathlib.Path(server_module.__file__).resolve() + ) + assert inspection["globalSettings"] == options["globalSettings"] + key = str(pathlib.Path(uris.to_fs_path(root.as_uri()))) + assert inspection["settings"] == { + key: {**options["settings"][0], "workspaceFS": key} + } + + client.send({"jsonrpc": "2.0", "id": 3, "method": "shutdown", "params": None}) + assert client.response(3) is None + client.send({"jsonrpc": "2.0", "method": "exit", "params": None}) + assert client.process.wait(timeout=TIMEOUT) == 0 + finally: + client.close() + print(client.stderr, file=sys.stderr) + + client.assert_protocol_complete() + assert any( + message["method"] == "window/logMessage" + and message["params"]["message"] == "fixture initialized" + for message in client.notifications + ) + assert "fixture shutdown complete" in client.stderr + assert "fixture exit complete" in client.stderr diff --git a/typescript/tests/server.test.ts b/typescript/tests/server.test.ts index ef3cc60..1f834b7 100644 --- a/typescript/tests/server.test.ts +++ b/typescript/tests/server.test.ts @@ -11,7 +11,7 @@ import * as status from '../src/status'; import * as utilities from '../src/utilities'; import * as vscodeapi from '../src/vscodeapi'; import * as settingsModule from '../src/settings'; -import { IBaseSettings, ToolConfig } from '../src/types'; +import { IBaseSettings, IInitOptions, ToolConfig } from '../src/types'; import { PythonEnvironmentsProvider } from '../src/python'; // --------------------------------------------------------------------------- @@ -344,6 +344,12 @@ suite('restartServer', () => { const extensionSettings = [ makeSettings({ workspace: 'file:///workspace/project', interpreter: ['/project/python'] }), ]; + const globalSettings = makeSettings({ + workspace: process.cwd(), + importStrategy: 'fromEnvironment', + args: ['--global'], + }); + (settingsModule.getGlobalSettings as sinon.SinonStub).resolves(globalSettings); const result = await restartServer(makeRestartOptions({ extensionSettings })); @@ -352,8 +358,12 @@ suite('restartServer', () => { 'pre-resolved settings should be reused', ); const clientOptions = (result.client as unknown as { - clientOptions: { initializationOptions: { settings: IBaseSettings[] } }; + clientOptions: { initializationOptions: IInitOptions }; }).clientOptions; assert.strictEqual(clientOptions.initializationOptions.settings, extensionSettings); + assert.deepEqual(clientOptions.initializationOptions, { + settings: extensionSettings, + globalSettings, + }); }); }); diff --git a/typescript/tests/settingsWorkspace.test.ts b/typescript/tests/settingsWorkspace.test.ts index f32e919..e7456eb 100644 --- a/typescript/tests/settingsWorkspace.test.ts +++ b/typescript/tests/settingsWorkspace.test.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { assert } from 'chai'; +import { readFileSync } from 'fs'; +import * as path from 'path'; import * as sinon from 'sinon'; +import { pathToFileURL } from 'url'; import { Uri, WorkspaceFolder } from 'vscode'; import { checkIfConfigurationChanged, @@ -14,7 +17,7 @@ import { logLegacySettings, resolveVariables, } from '../src/settings'; -import { ToolConfig } from '../src/types'; +import { IInitOptions, ToolConfig } from '../src/types'; import * as vscodeapi from '../src/vscodeapi'; function makeWorkspace(name: string, fsPath: string, index: number = 0): WorkspaceFolder { @@ -55,6 +58,59 @@ suite('settings — workspace & global resolution', () => { sinon.restore(); }); + test('produces the shared initialization fixture with document CWD variables deferred', async () => { + const root = makeWorkspace('contract', path.resolve('settings workspace café')); + const rootUri = pathToFileURL(root.uri.fsPath).href; + sinon.stub(root.uri, 'toString').returns(rootUri); + getWorkspaceFoldersStub.returns([root]); + sinon.stub(vscodeapi, 'getWorkspaceFolder').returns(root); + const replacements = new Map([ + ['__ROOT_URI__', rootUri], + ['__ROOT_FS__', root.uri.fsPath], + ['__CWD_FS__', process.cwd()], + ['__PYTHON__', path.join(root.uri.fsPath, 'python')], + ['__SEP__', path.sep], + ]); + const fixturePath = path.resolve( + __dirname, + '..', + '..', + '..', + 'python', + 'tests', + 'fixtures', + 'settings', + 'producer.json', + ); + const fixture = JSON.parse(readFileSync(fixturePath, 'utf8'), (_key: string, value: unknown) => { + if (typeof value !== 'string') { + return value; + } + let expanded = value; + for (const [token, replacement] of replacements) { + expanded = expanded.split(token).join(replacement); + } + return expanded; + }) as { + toolDefaults: Record; + configuration: { workspace: Record; global: Record }; + initializationOptions: IInitOptions; + }; + getConfigurationStub.returns({ + get: (key: string, fallback?: unknown) => + key in fixture.configuration.workspace ? fixture.configuration.workspace[key] : fallback, + inspect: (key: string) => ({ globalValue: fixture.configuration.global[key] }), + }); + const toolConfig = makeToolConfig({ settingsDefaults: fixture.toolDefaults }); + + const actual: IInitOptions = { + settings: await getExtensionSettings('flake8', toolConfig, undefined, [root]), + globalSettings: await getGlobalSettings('flake8', toolConfig), + }; + + assert.deepEqual(actual, fixture.initializationOptions); + }); + suite('getWorkspaceSettings', () => { test('returns base settings with defaults', async () => { const configMap: Record = {}; @@ -260,6 +316,50 @@ suite('settings — workspace & global resolution', () => { assert.deepEqual(result.args, ['--strict']); }); + + for (const [label, globalValue, expected] of [ + ['null uses the contribution default', null, 'useBundled'], + ['empty string overrides the contribution default', '', ''], + ] as const) { + test(`preserves scope precedence: ${label}`, async () => { + const contributionDefaults: Record = { + importStrategy: 'useBundled', + args: ['--contribution'], + interpreter: ['/contribution/python'], + cwd: process.cwd(), + enabled: true, + severity: { E: 'Hint' }, + }; + const globalValues: Record = { + importStrategy: globalValue, + args: [], + interpreter: [], + cwd: '', + enabled: false, + severity: null, + }; + getConfigurationStub.returns({ + get: (key: string, fallback?: unknown) => + key === 'importStrategy' ? 'fromEnvironment' : fallback, + inspect: (key: string) => ({ + globalValue: globalValues[key], + defaultValue: contributionDefaults[key], + }), + }); + const toolConfig = makeToolConfig(); + + const global = await getGlobalSettings('flake8', toolConfig); + const workspace = await getWorkspaceSettings('flake8', ws, toolConfig); + + assert.equal(global.importStrategy, expected); + assert.equal(workspace.importStrategy, 'fromEnvironment'); + assert.deepEqual(global.args, []); + assert.deepEqual(global.interpreter, []); + assert.equal(global.cwd, ''); + assert.isFalse(global.enabled); + assert.deepEqual(global.severity, { E: 'Hint' }); + }); + } }); suite('getExtraPaths', () => {