diff --git a/.github/release_plan.md b/.github/release_plan.md index ecff43a28ee0..076cd64132dd 100644 --- a/.github/release_plan.md +++ b/.github/release_plan.md @@ -4,27 +4,22 @@ All dates should align with VS Code's [iteration](https://github.com/microsoft/v Feature freeze is Monday @ 17:00 America/Vancouver, XXX XX. At that point, commits to `main` should only be in response to bugs found during endgame testing until the release candidate is ready.
- Release Primary and Secondary Assignments for the 2024 Calendar Year - -| Month | Primary | Secondary | -|:----------|:----------|:------------| -| ~~January~~ | ~~Eleanor~~ | ~~Karthik~~ | -| ~~February~~ | ~~Kartik~~ | ~~Anthony~~ | -| ~~March~~ | ~~Karthik~~ | ~~Eleanor~~ | -| ~~April~~ | ~~Paula~~ | ~~Eleanor~~ | -| ~~May~~ | ~~Anthony~~ | ~~Karthik~~ | -| ~~June~~ | ~~Karthik~~ | ~~Eleanor~~ | -| July | Anthony | Karthik | -| August | Paula | Anthony | -| September | Anthony | Eleanor | -| October | Paula | Karthik | -| November | Eleanor | Paula | -| December | Eleanor | Anthony | - -Paula: 3 primary, 2 secondary -Eleanor: 3 primary (2 left), 3 secondary (2 left) -Anthony: 2 primary, 3 secondary (2 left) -Karthik: 2 primary (1 left), 4 secondary (3 left) + Release Primary and Secondary Assignments for the 2025 Calendar Year + +| Month and version number | Primary | Secondary | +|------------|----------|-----------| +| January v2025.0.0 | Eleanor | Karthik | +| February v2025.2.0 | Anthony | Eleanor | +| March v2025.4.0 | Karthik | Anthony | +| April v2025.6.0 | Eleanor | Karthik | +| May v2025.8.0 | Anthony | Eleanor | +| June v2025.10.0 | Karthik | Anthony | +| July v2025.12.0 | Eleanor | Karthik | +| August v2025.14.0 | Anthony | Eleanor | +| September v2025.16.0 | Karthik | Anthony | +| October v2025.18.0 | Eleanor | Karthik | +| November v2025.20.0 | Anthony | Eleanor | +| December v2025.22.0 | Karthik | Anthony |
diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml deleted file mode 100644 index e3a2d8600159..000000000000 --- a/.github/workflows/stale-prs.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Warn about month-old PRs - -on: - schedule: - - cron: '0 0 */2 * *' # Runs every other day at midnight - -jobs: - stale-prs: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Warn about stale PRs - uses: actions/github-script@v7 - with: - script: | - const { Octokit } = require("@octokit/rest"); - const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - - const owner = context.repo.owner; - const repo = context.repo.repo; - const staleTime = new Date(); - staleTime.setMonth(staleTime.getMonth() - 1); - - const prs = await octokit.pulls.list({ - owner, - repo, - state: 'open' - }); - - for (const pr of prs.data) { - const comments = await octokit.issues.listComments({ - owner, - repo, - issue_number: pr.number - }); - - const lastComment = comments.data.length > 0 ? new Date(comments.data[comments.data.length - 1].created_at) : new Date(pr.created_at); - - if (lastComment < staleTime) { - await octokit.issues.createComment({ - owner, - repo, - issue_number: pr.number, - body: 'This PR has been stale for over a month. Please update or close it.' - }); - } - } - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build/test-requirements.txt b/build/test-requirements.txt index af19987bc8cb..8b0ea1636157 100644 --- a/build/test-requirements.txt +++ b/build/test-requirements.txt @@ -36,3 +36,6 @@ pytest-json # for pytest-describe related tests pytest-describe + +# for pytest-ruff related tests +pytest-ruff diff --git a/package.json b/package.json index 72e05327d8d4..7f7df96289d7 100644 --- a/package.json +++ b/package.json @@ -1147,12 +1147,6 @@ "command": "python.execInInteractiveWindowEnter", "key": "enter", "when": "!config.interactiveWindow.executeWithShiftEnter && isCompositeNotebook && activeEditor == 'workbench.editor.interactive' && !inlineChatFocused && !notebookCellListFocused" - }, - { - "command": "python.refreshTensorBoard", - "key": "ctrl+r", - "mac": "cmd+r", - "when": "python.hasActiveTensorBoardSession" } ], "languages": [ @@ -1302,20 +1296,6 @@ "title": "%python.command.python.execInREPL.title%", "when": "false" }, - { - "category": "Python", - "command": "python.launchTensorBoard", - "title": "%python.command.python.launchTensorBoard.title%", - "when": "!virtualWorkspace && shellExecutionSupported && !python.tensorboardExtInstalled" - }, - { - "category": "Python", - "command": "python.refreshTensorBoard", - "enablement": "python.hasActiveTensorBoardSession", - "icon": "$(refresh)", - "title": "%python.command.python.refreshTensorBoard.title%", - "when": "!virtualWorkspace && shellExecutionSupported && !python.tensorboardExtInstalled" - }, { "category": "Python", "command": "python.reportIssue", @@ -1414,13 +1394,6 @@ "when": "editorFocus && editorLangId == python && !virtualWorkspace && shellExecutionSupported && config.python.REPL.sendToNativeREPL" } ], - "editor/title": [ - { - "command": "python.refreshTensorBoard", - "group": "navigation@0", - "when": "python.hasActiveTensorBoardSession && !virtualWorkspace && shellExecutionSupported" - } - ], "editor/title/run": [ { "command": "python.execInTerminal-icon", diff --git a/python_files/tests/pytestadapter/.data/folder_with_script/script_random.py b/python_files/tests/pytestadapter/.data/folder_with_script/script_random.py new file mode 100644 index 000000000000..d8c32027a9e6 --- /dev/null +++ b/python_files/tests/pytestadapter/.data/folder_with_script/script_random.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# This file has no test, it's just a random script. + +if __name__ == "__main__": + print("Hello World!") diff --git a/python_files/tests/pytestadapter/.data/folder_with_script/test_simple.py b/python_files/tests/pytestadapter/.data/folder_with_script/test_simple.py new file mode 100644 index 000000000000..9f9bfb014f3d --- /dev/null +++ b/python_files/tests/pytestadapter/.data/folder_with_script/test_simple.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +# This test passes. +def test_function(): # test_marker--test_function + assert 1 == 1 diff --git a/python_files/tests/pytestadapter/expected_discovery_test_output.py b/python_files/tests/pytestadapter/expected_discovery_test_output.py index aa74a424ea2a..d7e82acc6890 100644 --- a/python_files/tests/pytestadapter/expected_discovery_test_output.py +++ b/python_files/tests/pytestadapter/expected_discovery_test_output.py @@ -1577,3 +1577,94 @@ ], "id_": TEST_DATA_PATH_STR, } +# This is the expected output for the folder_with_script folder when run with ruff +# └── .data +# └── folder_with_script +# └── script_random.py +# └── ruff +# └── test_simple.py +# └── ruff +# └── test_function +ruff_test_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "folder_with_script", + "path": os.fspath(TEST_DATA_PATH / "folder_with_script"), + "type_": "folder", + "id_": os.fspath(TEST_DATA_PATH / "folder_with_script"), + "children": [ + { + "name": "script_random.py", + "path": os.fspath(TEST_DATA_PATH / "folder_with_script" / "script_random.py"), + "type_": "file", + "id_": os.fspath(TEST_DATA_PATH / "folder_with_script" / "script_random.py"), + "children": [ + { + "name": "ruff", + "path": os.fspath( + TEST_DATA_PATH / "folder_with_script" / "script_random.py" + ), + "lineno": "", + "type_": "test", + "id_": get_absolute_test_id( + "folder_with_script/script_random.py::ruff", + TEST_DATA_PATH / "folder_with_script" / "script_random.py", + ), + "runID": get_absolute_test_id( + "folder_with_script/script_random.py::ruff", + TEST_DATA_PATH / "folder_with_script" / "script_random.py", + ), + } + ], + }, + { + "name": "test_simple.py", + "path": os.fspath(TEST_DATA_PATH / "folder_with_script" / "test_simple.py"), + "type_": "file", + "id_": os.fspath(TEST_DATA_PATH / "folder_with_script" / "test_simple.py"), + "children": [ + { + "name": "ruff", + "path": os.fspath( + TEST_DATA_PATH / "folder_with_script" / "test_simple.py" + ), + "lineno": "", + "type_": "test", + "id_": get_absolute_test_id( + "folder_with_script/test_simple.py::ruff", + TEST_DATA_PATH / "folder_with_script" / "test_simple.py", + ), + "runID": get_absolute_test_id( + "folder_with_script/test_simple.py::ruff", + TEST_DATA_PATH / "folder_with_script" / "test_simple.py", + ), + }, + { + "name": "test_function", + "path": os.fspath( + TEST_DATA_PATH / "folder_with_script" / "test_simple.py" + ), + "lineno": find_test_line_number( + "test_function", + TEST_DATA_PATH / "folder_with_script" / "test_simple.py", + ), + "type_": "test", + "id_": get_absolute_test_id( + "folder_with_script/test_simple.py::test_function", + TEST_DATA_PATH / "folder_with_script" / "test_simple.py", + ), + "runID": get_absolute_test_id( + "folder_with_script/test_simple.py::test_function", + TEST_DATA_PATH / "folder_with_script" / "test_simple.py", + ), + }, + ], + }, + ], + } + ], + "id_": TEST_DATA_PATH_STR, +} diff --git a/python_files/tests/pytestadapter/test_discovery.py b/python_files/tests/pytestadapter/test_discovery.py index 276753149410..4f9fe3eb19ac 100644 --- a/python_files/tests/pytestadapter/test_discovery.py +++ b/python_files/tests/pytestadapter/test_discovery.py @@ -195,15 +195,17 @@ def test_pytest_collect(file, expected_const): if actual_list is not None: actual_item = actual_list.pop(0) assert all(item in actual_item for item in ("status", "cwd", "error")) - assert ( - actual_item.get("status") == "success" - ), f"Status is not 'success', error is: {actual_item.get('error')}" + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) assert actual_item.get("cwd") == os.fspath(helpers.TEST_DATA_PATH) assert is_same_tree( actual_item.get("tests"), expected_const, ["id_", "lineno", "name", "runID"], - ), f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_const, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ), ( + f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_const, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) @pytest.mark.skipif( @@ -232,13 +234,13 @@ def test_symlink_root_dir(): actual_item = actual_list.pop(0) try: # Check if all requirements - assert all( - item in actual_item for item in ("status", "cwd", "error") - ), "Required keys are missing" + assert all(item in actual_item for item in ("status", "cwd", "error")), ( + "Required keys are missing" + ) assert actual_item.get("status") == "success", "Status is not 'success'" - assert actual_item.get("cwd") == os.fspath( - destination - ), f"CWD does not match: {os.fspath(destination)}" + assert actual_item.get("cwd") == os.fspath(destination), ( + f"CWD does not match: {os.fspath(destination)}" + ) assert actual_item.get("tests") == expected, "Tests do not match expected value" except AssertionError as e: # Print the actual_item in JSON format if an assertion fails @@ -271,7 +273,9 @@ def test_pytest_root_dir(): actual_item.get("tests"), expected_discovery_test_output.root_with_config_expected_output, ["id_", "lineno", "name", "runID"], - ), f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.root_with_config_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ), ( + f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.root_with_config_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) def test_pytest_config_file(): @@ -298,7 +302,9 @@ def test_pytest_config_file(): actual_item.get("tests"), expected_discovery_test_output.root_with_config_expected_output, ["id_", "lineno", "name", "runID"], - ), f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.root_with_config_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ), ( + f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.root_with_config_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) def test_config_sub_folder(): @@ -329,3 +335,32 @@ def test_config_sub_folder(): if actual_item.get("tests") is not None: tests: Any = actual_item.get("tests") assert tests.get("name") == "config_sub_folder" + + +def test_ruff_plugin(): + """Here the session node will be a subfolder of the workspace root and the test are in another subfolder. + + This tests checks to see if test node path are under the session node and if so the + session node is correctly updated to the common path. + """ + file_path = helpers.TEST_DATA_PATH / "folder_with_script" + actual = helpers.runner( + [os.fspath(file_path), "--collect-only", "--ruff"], + ) + + assert actual + actual_list: List[Dict[str, Any]] = actual + if actual_list is not None: + actual_item = actual_list.pop(0) + assert all(item in actual_item for item in ("status", "cwd", "error")) + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) + assert actual_item.get("cwd") == os.fspath(helpers.TEST_DATA_PATH) + assert is_same_tree( + actual_item.get("tests"), + expected_discovery_test_output.ruff_test_expected_output, + ["id_", "lineno", "name", "runID"], + ), ( + f"Tests tree does not match expected value. \n Expected: {json.dumps(expected_discovery_test_output.ruff_test_expected_output, indent=4)}. \n Actual: {json.dumps(actual_item.get('tests'), indent=4)}" + ) diff --git a/python_files/tests/pytestadapter/test_execution.py b/python_files/tests/pytestadapter/test_execution.py index 27fd1160441b..95a66e0e7b87 100644 --- a/python_files/tests/pytestadapter/test_execution.py +++ b/python_files/tests/pytestadapter/test_execution.py @@ -258,13 +258,13 @@ def test_symlink_run(): actual_item = actual_list.pop(0) try: # Check if all requirements - assert all( - item in actual_item for item in ("status", "cwd", "result") - ), "Required keys are missing" + assert all(item in actual_item for item in ("status", "cwd", "result")), ( + "Required keys are missing" + ) assert actual_item.get("status") == "success", "Status is not 'success'" - assert actual_item.get("cwd") == os.fspath( - destination - ), f"CWD does not match: {os.fspath(destination)}" + assert actual_item.get("cwd") == os.fspath(destination), ( + f"CWD does not match: {os.fspath(destination)}" + ) actual_result_dict = {} actual_result_dict.update(actual_item["result"]) assert actual_result_dict == expected_const diff --git a/python_files/tests/unittestadapter/test_discovery.py b/python_files/tests/unittestadapter/test_discovery.py index 972556de999b..a10b5c406680 100644 --- a/python_files/tests/unittestadapter/test_discovery.py +++ b/python_files/tests/unittestadapter/test_discovery.py @@ -314,9 +314,9 @@ def test_simple_django_collect(): if actual_list is not None: actual_item = actual_list.pop(0) assert all(item in actual_item for item in ("status", "cwd")) - assert ( - actual_item.get("status") == "success" - ), f"Status is not 'success', error is: {actual_item.get('error')}" + assert actual_item.get("status") == "success", ( + f"Status is not 'success', error is: {actual_item.get('error')}" + ) assert actual_item.get("cwd") == os.fspath(data_path) assert len(actual_item["tests"]["children"]) == 1 assert actual_item["tests"]["children"][0]["children"][0]["id_"] == os.fsdecode( diff --git a/python_files/unittestadapter/pvsc_utils.py b/python_files/unittestadapter/pvsc_utils.py index 34b8553600f1..4d1cbfb5e110 100644 --- a/python_files/unittestadapter/pvsc_utils.py +++ b/python_files/unittestadapter/pvsc_utils.py @@ -350,7 +350,6 @@ def send_post_request( encoded = request.encode("utf-8") bytes_written = 0 while bytes_written < len(encoded): - print("writing more bytes!") segment = encoded[bytes_written : bytes_written + size] bytes_written += __writer.write(segment) __writer.flush() diff --git a/python_files/vscode_pytest/__init__.py b/python_files/vscode_pytest/__init__.py index 1e812e41d2ae..0ba5fd62221a 100644 --- a/python_files/vscode_pytest/__init__.py +++ b/python_files/vscode_pytest/__init__.py @@ -10,14 +10,7 @@ import pathlib import sys import traceback -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Generator, - Literal, - TypedDict, -) +from typing import TYPE_CHECKING, Any, Dict, Generator, Literal, TypedDict import pytest @@ -495,7 +488,7 @@ def build_test_tree(session: pytest.Session) -> TestNode: """ session_node = create_session_node(session) session_children_dict: dict[str, TestNode] = {} - file_nodes_dict: dict[Any, TestNode] = {} + file_nodes_dict: dict[str, TestNode] = {} class_nodes_dict: dict[str, TestNode] = {} function_nodes_dict: dict[str, TestNode] = {} @@ -544,11 +537,13 @@ def build_test_tree(session: pytest.Session) -> TestNode: function_test_node["children"].append(test_node) # Check if the parent node of the function is file, if so create/add to this file node. if isinstance(test_case.parent, pytest.File): + # calculate the parent path of the test case + parent_path = get_node_path(test_case.parent) try: - parent_test_case = file_nodes_dict[test_case.parent] + parent_test_case = file_nodes_dict[os.fspath(parent_path)] except KeyError: - parent_test_case = create_file_node(test_case.parent) - file_nodes_dict[test_case.parent] = parent_test_case + parent_test_case = create_file_node(parent_path) + file_nodes_dict[os.fspath(parent_path)] = parent_test_case if function_test_node not in parent_test_case["children"]: parent_test_case["children"].append(function_test_node) # If the parent is not a file, it is a class, add the function node as the test node to handle subsequent nesting. @@ -580,22 +575,24 @@ def build_test_tree(session: pytest.Session) -> TestNode: else: ERRORS.append(f"Test class {case_iter} has no parent") break + parent_path = get_node_path(parent_module) # Create a file node that has the last class as a child. try: - test_file_node: TestNode = file_nodes_dict[parent_module] + test_file_node: TestNode = file_nodes_dict[os.fspath(parent_path)] except KeyError: - test_file_node = create_file_node(parent_module) - file_nodes_dict[parent_module] = test_file_node + test_file_node = create_file_node(parent_path) + file_nodes_dict[os.fspath(parent_path)] = test_file_node # Check if the class is already a child of the file node. if test_class_node is not None and test_class_node not in test_file_node["children"]: test_file_node["children"].append(test_class_node) elif not hasattr(test_case, "callspec"): # This includes test cases that are pytest functions or a doctests. + parent_path = get_node_path(test_case.parent) try: - parent_test_case = file_nodes_dict[test_case.parent] + parent_test_case = file_nodes_dict[os.fspath(parent_path)] except KeyError: - parent_test_case = create_file_node(test_case.parent) - file_nodes_dict[test_case.parent] = parent_test_case + parent_test_case = create_file_node(parent_path) + file_nodes_dict[os.fspath(parent_path)] = parent_test_case parent_test_case["children"].append(test_node) created_files_folders_dict: dict[str, TestNode] = {} for file_node in file_nodes_dict.values(): @@ -753,18 +750,17 @@ def create_parameterized_function_node( } -def create_file_node(file_module: Any) -> TestNode: - """Creates a file node from a pytest file module. +def create_file_node(calculated_node_path: pathlib.Path) -> TestNode: + """Creates a file node from a path which has already been calculated using the get_node_path function. Keyword arguments: - file_module -- the pytest file module. + calculated_node_path -- the pytest file path. """ - node_path = get_node_path(file_module) return { - "name": node_path.name, - "path": node_path, + "name": calculated_node_path.name, + "path": calculated_node_path, "type_": "file", - "id_": os.fspath(node_path), + "id_": os.fspath(calculated_node_path), "children": [], } diff --git a/src/client/common/terminal/service.ts b/src/client/common/terminal/service.ts index b02670836015..674d243e415d 100644 --- a/src/client/common/terminal/service.ts +++ b/src/client/common/terminal/service.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { inject, injectable } from 'inversify'; -import { CancellationToken, Disposable, Event, EventEmitter, Terminal, TerminalShellExecution } from 'vscode'; +import { window, CancellationToken, Disposable, Event, EventEmitter, Terminal, TerminalShellExecution } from 'vscode'; import '../../common/extensions'; import { IInterpreterService } from '../../interpreter/contracts'; import { IServiceContainer } from '../../ioc/types'; @@ -88,6 +88,18 @@ export class TerminalService implements ITerminalService, Disposable { terminal.show(true); } + window.onDidChangeTerminalState((e) => { + const ourTerminalState = e.state; + console.log(ourTerminalState); + traceVerbose('Printing our terminal state from service.ts', ourTerminalState); + + const ourStateTypeless = (ourTerminalState as unknown) as { isInteractedWith: any; shellType: any }; + const ourState = ourStateTypeless.isInteractedWith; + traceVerbose('Printing our terminal state from service.ts', ourState); + const ourShellType = ourStateTypeless.shellType; + traceVerbose('Printing our terminal state from service.ts', ourShellType); + traceVerbose('finished printing our terminal state'); + }); // If terminal was just launched, wait some time for shell integration to onDidChangeShellIntegration. if (!terminal.shellIntegration && this._terminalFirstLaunched) { this._terminalFirstLaunched = false; @@ -96,6 +108,12 @@ export class TerminalService implements ITerminalService, Disposable { clearTimeout(timer); disposable.dispose(); resolve(true); + + const shellIntegration = (terminal.shellIntegration as unknown) as { env: any }; + const tempEnv = shellIntegration.env; + console.log(tempEnv); + traceVerbose('Printing temp env from service.ts in terminal1', tempEnv); + traceVerbose('finished printing temp env '); }); const TIMEOUT_DURATION = 500; const timer = setTimeout(() => { @@ -114,7 +132,13 @@ export class TerminalService implements ITerminalService, Disposable { return undefined; } else if (terminal.shellIntegration) { const execution = terminal.shellIntegration.executeCommand(commandLine); - traceVerbose(`Shell Integration is enabled, executeCommand: ${commandLine}`); + + const shellIntegration = (terminal.shellIntegration as unknown) as { env: any }; + const tempEnv = shellIntegration.env; + console.log(tempEnv); + traceVerbose('Printing temp env from service.ts in terminal2', tempEnv); + traceVerbose('finished printing temp env '); + // traceVerbose(`Shell Integration is enabled, executeCommand: ${commandLine}`); return execution; } else { terminal.sendText(commandLine); diff --git a/src/client/common/utils/platform.ts b/src/client/common/utils/platform.ts index c86f5ff9364e..a1a49ba3c427 100644 --- a/src/client/common/utils/platform.ts +++ b/src/client/common/utils/platform.ts @@ -71,3 +71,11 @@ export function getUserHomeDir(): string | undefined { export function isWindows(): boolean { return getOSType() === OSType.Windows; } + +export function getPathEnvVariable(): string[] { + const value = getEnvironmentVariable('PATH') || getEnvironmentVariable('Path'); + if (value) { + return value.split(isWindows() ? ';' : ':'); + } + return []; +} diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 38f2d6a56277..b325e66acd34 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -55,6 +55,7 @@ import { registerReplCommands, registerReplExecuteOnEnter, registerStartNativeRe import { registerTriggerForTerminalREPL } from './terminals/codeExecution/terminalReplWatcher'; import { registerPythonStartup } from './terminals/pythonStartup'; import { registerPixiFeatures } from './pythonEnvironments/common/environmentManagers/pixi'; +import { traceVerbose } from './logging'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -115,6 +116,20 @@ export function activateFeatures(ext: ExtensionState, _components: Components): registerStartNativeReplCommand(ext.disposables, interpreterService); registerReplCommands(ext.disposables, interpreterService, executionHelper, commandManager); registerReplExecuteOnEnter(ext.disposables, interpreterService, commandManager); + + window.onDidChangeTerminalState((e) => { + const ourTerminalState = e.state; + console.log(ourTerminalState); + traceVerbose('Printing our terminal state from service.ts', ourTerminalState); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ourStateTypeless = (ourTerminalState as unknown) as { isInteractedWith: any; shellType: any }; + const ourState = ourStateTypeless.isInteractedWith; + traceVerbose('Printing our terminal state from service.ts', ourState); + const ourShellType = ourStateTypeless.shellType; + traceVerbose('Printing our terminal state from service.ts', ourShellType); + traceVerbose('finished printing our terminal state'); + }); } /// ////////////////////////// diff --git a/src/client/pythonEnvironments/common/environmentManagers/conda.ts b/src/client/pythonEnvironments/common/environmentManagers/conda.ts index bc60745dfeff..5301f82eda18 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/conda.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/conda.ts @@ -24,6 +24,7 @@ import { OUTPUT_MARKER_SCRIPT } from '../../../common/process/internal/scripts'; import { splitLines } from '../../../common/stringUtils'; import { SpawnOptions } from '../../../common/process/types'; import { sleep } from '../../../common/utils/async'; +import { getConfiguration } from '../../../common/vscodeApis/workspaceApis'; export const AnacondaCompanyName = 'Anaconda, Inc.'; export const CONDAPATH_SETTING_KEY = 'condaPath'; @@ -633,3 +634,8 @@ export async function getCondaEnvDirs(): Promise { const conda = await Conda.getConda(); return conda?.getEnvDirs(); } + +export function getCondaPathSetting(): string | undefined { + const config = getConfiguration('python'); + return config.get(CONDAPATH_SETTING_KEY, ''); +} diff --git a/src/client/pythonEnvironments/nativeAPI.ts b/src/client/pythonEnvironments/nativeAPI.ts index e069a3746ab6..a4a706fcb42b 100644 --- a/src/client/pythonEnvironments/nativeAPI.ts +++ b/src/client/pythonEnvironments/nativeAPI.ts @@ -20,14 +20,14 @@ import { NativePythonFinder, } from './base/locators/common/nativePythonFinder'; import { createDeferred, Deferred } from '../common/utils/async'; -import { Architecture, getUserHomeDir } from '../common/utils/platform'; +import { Architecture, getPathEnvVariable, getUserHomeDir } from '../common/utils/platform'; import { parseVersion } from './base/info/pythonVersion'; import { cache } from '../common/utils/decorators'; import { traceError, traceInfo, traceLog, traceWarn } from '../logging'; import { StopWatch } from '../common/utils/stopWatch'; import { FileChangeType } from '../common/platform/fileSystemWatcher'; import { categoryToKind, NativePythonEnvironmentKind } from './base/locators/common/nativePythonUtils'; -import { getCondaEnvDirs, setCondaBinary } from './common/environmentManagers/conda'; +import { getCondaEnvDirs, getCondaPathSetting, setCondaBinary } from './common/environmentManagers/conda'; import { setPyEnvBinary } from './common/environmentManagers/pyenv'; import { createPythonWatcher, @@ -166,6 +166,12 @@ function isSubDir(pathToCheck: string | undefined, parents: string[]): boolean { }); } +function foundOnPath(fsPath: string): boolean { + const paths = getPathEnvVariable().map((p) => path.normalize(p).toLowerCase()); + const normalized = path.normalize(fsPath).toLowerCase(); + return paths.some((p) => normalized.includes(p)); +} + function getName(nativeEnv: NativeEnvInfo, kind: PythonEnvKind, condaEnvDirs: string[]): string { if (nativeEnv.name) { return nativeEnv.name; @@ -387,13 +393,36 @@ class NativePythonEnvironments implements IDiscoveryAPI, Disposable { return undefined; } + private condaPathAlreadySet: string | undefined; + // eslint-disable-next-line class-methods-use-this private processEnvManager(native: NativeEnvManagerInfo) { const tool = native.tool.toLowerCase(); switch (tool) { case 'conda': - traceLog(`Conda environment manager found at: ${native.executable}`); - setCondaBinary(native.executable); + { + traceLog(`Conda environment manager found at: ${native.executable}`); + const settingPath = getCondaPathSetting(); + if (!this.condaPathAlreadySet) { + if (settingPath === '' || settingPath === undefined) { + if (foundOnPath(native.executable)) { + setCondaBinary(native.executable); + this.condaPathAlreadySet = native.executable; + traceInfo(`Using conda: ${native.executable}`); + } else { + traceInfo(`Conda not found on PATH, skipping: ${native.executable}`); + traceInfo( + 'You can set the path to conda using the setting: `python.condaPath` if you want to use a different conda binary', + ); + } + } else { + traceInfo(`Using conda from setting: ${settingPath}`); + this.condaPathAlreadySet = settingPath; + } + } else { + traceInfo(`Conda set to: ${this.condaPathAlreadySet}`); + } + } break; case 'pyenv': traceLog(`Pyenv environment manager found at: ${native.executable}`); diff --git a/src/client/testing/testController/common/resultResolver.ts b/src/client/testing/testController/common/resultResolver.ts index 2ce6039adba0..80e57edbabd2 100644 --- a/src/client/testing/testController/common/resultResolver.ts +++ b/src/client/testing/testController/common/resultResolver.ts @@ -189,8 +189,10 @@ export class PythonResultResolver implements ITestResultResolver { // search through freshly built array of testItem to find the failed test and update UI. testCases.forEach((indiItem) => { if (indiItem.id === grabVSid) { - if (indiItem.uri && indiItem.range) { - message.location = new Location(indiItem.uri, indiItem.range); + if (indiItem.uri) { + if (indiItem.range) { + message.location = new Location(indiItem.uri, indiItem.range); + } runInstance.errored(indiItem, message); } } @@ -210,8 +212,10 @@ export class PythonResultResolver implements ITestResultResolver { // search through freshly built array of testItem to find the failed test and update UI. testCases.forEach((indiItem) => { if (indiItem.id === grabVSid) { - if (indiItem.uri && indiItem.range) { - message.location = new Location(indiItem.uri, indiItem.range); + if (indiItem.uri) { + if (indiItem.range) { + message.location = new Location(indiItem.uri, indiItem.range); + } runInstance.failed(indiItem, message); } } @@ -222,7 +226,7 @@ export class PythonResultResolver implements ITestResultResolver { if (grabTestItem !== undefined) { testCases.forEach((indiItem) => { if (indiItem.id === grabVSid) { - if (indiItem.uri && indiItem.range) { + if (indiItem.uri) { runInstance.passed(grabTestItem); } } @@ -234,7 +238,7 @@ export class PythonResultResolver implements ITestResultResolver { if (grabTestItem !== undefined) { testCases.forEach((indiItem) => { if (indiItem.id === grabVSid) { - if (indiItem.uri && indiItem.range) { + if (indiItem.uri) { runInstance.skipped(grabTestItem); } } diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 692025a05f40..7139788a8177 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -156,18 +156,19 @@ export interface ITestResultResolver { } export interface ITestDiscoveryAdapter { // ** first line old method signature, second line new method signature - discoverTests(uri: Uri): Promise; + discoverTests(uri: Uri): Promise; discoverTests( uri: Uri, - executionFactory: IPythonExecutionFactory, + executionFactory?: IPythonExecutionFactory, + token?: CancellationToken, interpreter?: PythonEnvironment, - ): Promise; + ): Promise; } // interface for execution/runner adapter export interface ITestExecutionAdapter { // ** first line old method signature, second line new method signature - runTests(uri: Uri, testIds: string[], profileKind?: boolean | TestRunProfileKind): Promise; + runTests(uri: Uri, testIds: string[], profileKind?: boolean | TestRunProfileKind): Promise; runTests( uri: Uri, testIds: string[], @@ -176,7 +177,7 @@ export interface ITestExecutionAdapter { executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, - ): Promise; + ): Promise; } // Same types as in python_files/unittestadapter/utils.py diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index b6848d0245dc..68e10a2213d6 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -195,10 +195,10 @@ export function populateTestTree( const testItem = testController.createTestItem(child.id_, child.name, Uri.file(child.path)); testItem.tags = [RunTestTag, DebugTestTag]; - const range = new Range( - new Position(Number(child.lineno) - 1, 0), - new Position(Number(child.lineno), 0), - ); + let range: Range | undefined; + if (child.lineno) { + range = new Range(new Position(Number(child.lineno) - 1, 0), new Position(Number(child.lineno), 0)); + } testItem.canResolveChildren = false; testItem.range = range; testItem.tags = [RunTestTag, DebugTestTag]; diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index ff73b31435a3..ef68f7d8039d 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -1,15 +1,16 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import * as path from 'path'; -import { Uri } from 'vscode'; +import { CancellationToken, CancellationTokenSource, Uri } from 'vscode'; import * as fs from 'fs'; +import { ChildProcess } from 'child_process'; import { ExecutionFactoryCreateWithEnvironmentOptions, IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; -import { Deferred } from '../../../common/utils/async'; +import { createDeferred, Deferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging'; import { DiscoveredTestPayload, ITestDiscoveryAdapter, ITestResultResolver } from '../common/types'; @@ -40,24 +41,39 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { async discoverTests( uri: Uri, executionFactory?: IPythonExecutionFactory, + token?: CancellationToken, interpreter?: PythonEnvironment, - ): Promise { - const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { - this.resultResolver?.resolveDiscovery(data); + ): Promise { + const cSource = new CancellationTokenSource(); + const deferredReturn = createDeferred(); + + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled.`); + cSource.cancel(); + deferredReturn.resolve(); }); - await this.runPytestDiscovery(uri, name, executionFactory, interpreter); + const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { + // if the token is cancelled, we don't want process the data + if (!token?.isCancellationRequested) { + this.resultResolver?.resolveDiscovery(data); + } + }, cSource.token); + + this.runPytestDiscovery(uri, name, cSource, executionFactory, interpreter, token).then(() => { + deferredReturn.resolve(); + }); - // this is only a placeholder to handle function overloading until rewrite is finished - const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' }; - return discoveryPayload; + return deferredReturn.promise; } async runPytestDiscovery( uri: Uri, discoveryPipeName: string, + cSource: CancellationTokenSource, executionFactory?: IPythonExecutionFactory, interpreter?: PythonEnvironment, + token?: CancellationToken, ): Promise { const relativePathToPytest = 'python_files'; const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); @@ -111,6 +127,12 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { args: execArgs, env: (mutableEnv as unknown) as { [key: string]: string }, }); + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); + proc.kill(); + deferredTillExecClose.resolve(); + cSource.cancel(); + }); proc.stdout.on('data', (data) => { const out = fixLogLinesNoTrailing(data.toString()); traceInfo(out); @@ -143,6 +165,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { throwOnStdErr: true, outputChannel: this.outputChannel, env: mutableEnv, + token, }; // Create the Python environment in which to execute the command. @@ -154,7 +177,21 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { const execService = await executionFactory?.createActivatedEnvironment(creationOptions); const deferredTillExecClose: Deferred = createTestingDeferred(); + + let resultProc: ChildProcess | undefined; + + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); + // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. + if (resultProc) { + resultProc?.kill(); + } else { + deferredTillExecClose.resolve(); + cSource.cancel(); + } + }); const result = execService?.execObservable(execArgs, spawnOptions); + resultProc = result?.proc; // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. // Displays output to user and ensure the subprocess doesn't run into buffer overflow. diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index b408280a576e..f66bff584fe2 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -38,7 +38,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, - ): Promise { + ): Promise { const deferredTillServerClose: Deferred = utils.createTestingDeferred(); // create callback to handle data received on the named pipe @@ -59,12 +59,6 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { ); runInstance?.token.onCancellationRequested(() => { traceInfo(`Test run cancelled, resolving 'TillServerClose' deferred for ${uri.fsPath}.`); - const executionPayload: ExecutionTestPayload = { - cwd: uri.fsPath, - status: 'success', - error: '', - }; - return executionPayload; }); try { @@ -82,15 +76,6 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { } finally { await deferredTillServerClose.promise; } - - // placeholder until after the rewrite is adopted - // TODO: remove after adoption. - const executionPayload: ExecutionTestPayload = { - cwd: uri.fsPath, - status: 'success', - error: '', - }; - return executionPayload; } private async runTestsNew( @@ -244,7 +229,6 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { }); const result = execService?.execObservable(runArgs, spawnOptions); - resultProc = result?.proc; // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. // Displays output to user and ensure the subprocess doesn't run into buffer overflow. diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 04518e121651..73eb3f5aec2b 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -2,7 +2,9 @@ // Licensed under the MIT License. import * as path from 'path'; -import { Uri } from 'vscode'; +import { CancellationTokenSource, Uri } from 'vscode'; +import { CancellationToken } from 'vscode-jsonrpc'; +import { ChildProcess } from 'child_process'; import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { @@ -40,15 +42,31 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { private readonly envVarsService?: IEnvironmentVariablesProvider, ) {} - public async discoverTests(uri: Uri, executionFactory?: IPythonExecutionFactory): Promise { + public async discoverTests( + uri: Uri, + executionFactory?: IPythonExecutionFactory, + token?: CancellationToken, + ): Promise { const settings = this.configSettings.getSettings(uri); const { unittestArgs } = settings.testing; const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; - const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { - this.resultResolver?.resolveDiscovery(data); + const cSource = new CancellationTokenSource(); + // Create a deferred to return to the caller + const deferredReturn = createDeferred(); + + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled.`); + cSource.cancel(); + deferredReturn.resolve(); }); + const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { + if (!token?.isCancellationRequested) { + this.resultResolver?.resolveDiscovery(data); + } + }, cSource.token); + // set up env with the pipe name let env: EnvironmentVariables | undefined = await this.envVarsService?.getEnvironmentVariables(uri); if (env === undefined) { @@ -62,17 +80,14 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { command, cwd, outChannel: this.outputChannel, + token, }; - try { - await this.runDiscovery(uri, options, name, cwd, executionFactory); - } finally { - // none - } - // placeholder until after the rewrite is adopted - // TODO: remove after adoption. - const discoveryPayload: DiscoveredTestPayload = { cwd, status: 'success' }; - return discoveryPayload; + this.runDiscovery(uri, options, name, cwd, cSource, executionFactory).then(() => { + deferredReturn.resolve(); + }); + + return deferredReturn.promise; } async runDiscovery( @@ -80,6 +95,7 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { options: TestCommandOptions, testRunPipeName: string, cwd: string, + cSource: CancellationTokenSource, executionFactory?: IPythonExecutionFactory, ): Promise { // get and edit env vars @@ -103,6 +119,12 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { args, env: (mutableEnv as unknown) as { [key: string]: string }, }); + options.token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing unittest subprocess for workspace ${uri.fsPath}`); + proc.kill(); + deferredTillExecClose.resolve(); + cSource.cancel(); + }); proc.stdout.on('data', (data) => { const out = fixLogLinesNoTrailing(data.toString()); traceInfo(out); @@ -148,7 +170,19 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { }; const execService = await executionFactory?.createActivatedEnvironment(creationOptions); + let resultProc: ChildProcess | undefined; + options.token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing unittest subprocess for workspace ${uri.fsPath}`); + // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. + if (resultProc) { + resultProc?.kill(); + } else { + deferredTillExecClose.resolve(); + cSource.cancel(); + } + }); const result = execService?.execObservable(args, spawnOptions); + resultProc = result?.proc; // Displays output to user and ensure the subprocess doesn't run into buffer overflow. // TODO: after a release, remove discovery output from the "Python Test Log" channel and send it to the "Python" channel instead. diff --git a/src/client/testing/testController/unittest/testExecutionAdapter.ts b/src/client/testing/testController/unittest/testExecutionAdapter.ts index 6db36d96149f..e2b591379335 100644 --- a/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -47,7 +47,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { runInstance?: TestRun, executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, - ): Promise { + ): Promise { // deferredTillServerClose awaits named pipe server close const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -87,12 +87,6 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { } finally { await deferredTillServerClose.promise; } - const executionPayload: ExecutionTestPayload = { - cwd: uri.fsPath, - status: 'success', - error: '', - }; - return executionPayload; } private async runTestsNew( diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index d8d6cb53d835..a73acdaba5f0 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -134,7 +134,7 @@ export class WorkspaceTestAdapter { try { // ** execution factory only defined for new rewrite way if (executionFactory !== undefined) { - await this.discoveryAdapter.discoverTests(this.workspaceUri, executionFactory, interpreter); + await this.discoveryAdapter.discoverTests(this.workspaceUri, executionFactory, token, interpreter); } else { await this.discoveryAdapter.discoverTests(this.workspaceUri); } diff --git a/src/test/pythonEnvironments/nativeAPI.unit.test.ts b/src/test/pythonEnvironments/nativeAPI.unit.test.ts index 678a8fcfe2e3..74811fa63bb6 100644 --- a/src/test/pythonEnvironments/nativeAPI.unit.test.ts +++ b/src/test/pythonEnvironments/nativeAPI.unit.test.ts @@ -13,7 +13,7 @@ import { NativeEnvManagerInfo, NativePythonFinder, } from '../../client/pythonEnvironments/base/locators/common/nativePythonFinder'; -import { Architecture, isWindows } from '../../client/common/utils/platform'; +import { Architecture, getPathEnvVariable, isWindows } from '../../client/common/utils/platform'; import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from '../../client/pythonEnvironments/base/info'; import { NativePythonEnvironmentKind } from '../../client/pythonEnvironments/base/locators/common/nativePythonUtils'; import * as condaApi from '../../client/pythonEnvironments/common/environmentManagers/conda'; @@ -25,6 +25,8 @@ suite('Native Python API', () => { let api: IDiscoveryAPI; let mockFinder: typemoq.IMock; let setCondaBinaryStub: sinon.SinonStub; + let getCondaPathSettingStub: sinon.SinonStub; + let getCondaEnvDirsStub: sinon.SinonStub; let setPyEnvBinaryStub: sinon.SinonStub; let createPythonWatcherStub: sinon.SinonStub; let mockWatcher: typemoq.IMock; @@ -136,6 +138,8 @@ suite('Native Python API', () => { setup(() => { setCondaBinaryStub = sinon.stub(condaApi, 'setCondaBinary'); + getCondaEnvDirsStub = sinon.stub(condaApi, 'getCondaEnvDirs'); + getCondaPathSettingStub = sinon.stub(condaApi, 'getCondaPathSetting'); setPyEnvBinaryStub = sinon.stub(pyenvApi, 'setPyEnvBinary'); getWorkspaceFoldersStub = sinon.stub(ws, 'getWorkspaceFolders'); getWorkspaceFoldersStub.returns([]); @@ -294,9 +298,12 @@ suite('Native Python API', () => { }); test('Setting conda binary', async () => { + getCondaPathSettingStub.returns(undefined); + getCondaEnvDirsStub.resolves(undefined); + const condaFakeDir = getPathEnvVariable()[0]; const condaMgr: NativeEnvManagerInfo = { tool: 'Conda', - executable: '/usr/bin/conda', + executable: path.join(condaFakeDir, 'conda'), }; mockFinder .setup((f) => f.refresh()) diff --git a/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts index 538b77161483..852942715270 100644 --- a/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts @@ -2,7 +2,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import * as assert from 'assert'; -import { Uri } from 'vscode'; +import { Uri, CancellationTokenSource } from 'vscode'; import * as typeMoq from 'typemoq'; import * as path from 'path'; import { Observable } from 'rxjs/Observable'; @@ -13,6 +13,7 @@ import { PytestTestDiscoveryAdapter } from '../../../../client/testing/testContr import { IPythonExecutionFactory, IPythonExecutionService, + // eslint-disable-next-line @typescript-eslint/no-unused-vars SpawnOptions, Output, } from '../../../../client/common/process/types'; @@ -31,11 +32,13 @@ suite('pytest test discovery adapter', () => { let outputChannel: typeMoq.IMock; let expectedPath: string; let uri: Uri; + // eslint-disable-next-line @typescript-eslint/no-unused-vars let expectedExtraVariables: Record; let mockProc: MockChildProcess; let deferred2: Deferred; let utilsStartDiscoveryNamedPipeStub: sinon.SinonStub; let useEnvExtensionStub: sinon.SinonStub; + let cancellationTokenSource: CancellationTokenSource; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); @@ -86,9 +89,12 @@ suite('pytest test discovery adapter', () => { }, }; }); + + cancellationTokenSource = new CancellationTokenSource(); }); teardown(() => { sinon.restore(); + cancellationTokenSource.dispose(); }); test('Discovery should call exec with correct basic args', async () => { // set up exec mock @@ -333,4 +339,77 @@ suite('pytest test discovery adapter', () => { typeMoq.Times.once(), ); }); + test('Test discovery canceled before exec observable call finishes', async () => { + // set up exec mock + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execService.object)); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + adapter = new PytestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // Trigger cancellation before exec observable call finishes + cancellationTokenSource.cancel(); + + await discoveryPromise; + + assert.ok( + true, + 'Test resolves correctly when triggering a cancellation token immediately after starting discovery.', + ); + }); + + test('Test discovery cancelled while exec observable is running and proc is closed', async () => { + // + const execService2 = typeMoq.Mock.ofType(); + execService2.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execService2 + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + // Trigger cancellation while exec observable is running + cancellationTokenSource.cancel(); + return { + proc: mockProc as any, + out: new Observable>(), + dispose: () => { + /* no-body */ + }, + }; + }); + // set up exec mock + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService2.object); + }); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + adapter = new PytestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // add in await and trigger + await discoveryPromise; + assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.'); + }); }); diff --git a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts index a0ee65d57922..911a5f89afb4 100644 --- a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts @@ -4,8 +4,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; import * as path from 'path'; -import * as typemoq from 'typemoq'; -import { Uri } from 'vscode'; +import * as typeMoq from 'typemoq'; +import * as fs from 'fs'; +import { CancellationTokenSource, Uri } from 'vscode'; import { Observable } from 'rxjs'; import * as sinon from 'sinon'; import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; @@ -23,38 +24,39 @@ import { import * as extapi from '../../../../client/envExt/api.internal'; suite('Unittest test discovery adapter', () => { - let stubConfigSettings: IConfigurationService; - let outputChannel: typemoq.IMock; + let configService: IConfigurationService; + let outputChannel: typeMoq.IMock; let mockProc: MockChildProcess; - let execService: typemoq.IMock; - let execFactory = typemoq.Mock.ofType(); + let execService: typeMoq.IMock; + let execFactory = typeMoq.Mock.ofType(); let deferred: Deferred; let expectedExtraVariables: Record; let expectedPath: string; let uri: Uri; let utilsStartDiscoveryNamedPipeStub: sinon.SinonStub; let useEnvExtensionStub: sinon.SinonStub; + let cancellationTokenSource: CancellationTokenSource; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); useEnvExtensionStub.returns(false); expectedPath = path.join('/', 'new', 'cwd'); - stubConfigSettings = ({ + configService = ({ getSettings: () => ({ testing: { unittestArgs: ['-v', '-s', '.', '-p', 'test*'] }, }), } as unknown) as IConfigurationService; - outputChannel = typemoq.Mock.ofType(); + outputChannel = typeMoq.Mock.ofType(); // set up exec service with child process mockProc = new MockChildProcess('', ['']); const output = new Observable>(() => { /* no op */ }); - execService = typemoq.Mock.ofType(); + execService = typeMoq.Mock.ofType(); execService - .setup((x) => x.execObservable(typemoq.It.isAny(), typemoq.It.isAny())) + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => { deferred.resolve(); console.log('execObservable is returning'); @@ -66,10 +68,10 @@ suite('Unittest test discovery adapter', () => { }, }; }); - execFactory = typemoq.Mock.ofType(); + execFactory = typeMoq.Mock.ofType(); deferred = createDeferred(); execFactory - .setup((x) => x.createActivatedEnvironment(typemoq.It.isAny())) + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) .returns(() => Promise.resolve(execService.object)); execFactory.setup((p) => ((p as unknown) as any).then).returns(() => undefined); execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); @@ -83,13 +85,15 @@ suite('Unittest test discovery adapter', () => { utilsStartDiscoveryNamedPipeStub = sinon.stub(util, 'startDiscoveryNamedPipe'); utilsStartDiscoveryNamedPipeStub.callsFake(() => Promise.resolve('discoveryResultPipe-mockName')); + cancellationTokenSource = new CancellationTokenSource(); }); teardown(() => { sinon.restore(); + cancellationTokenSource.dispose(); }); test('DiscoverTests should send the discovery command to the test server with the correct args', async () => { - const adapter = new UnittestTestDiscoveryAdapter(stubConfigSettings, outputChannel.object); + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); adapter.discoverTests(uri, execFactory.object); const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; @@ -100,7 +104,7 @@ suite('Unittest test discovery adapter', () => { execService.verify( (x) => x.execObservable( - typemoq.It.is>((argsActual) => { + typeMoq.It.is>((argsActual) => { try { assert.equal(argsActual.length, argsExpected.length); assert.deepEqual(argsActual, argsExpected); @@ -110,7 +114,7 @@ suite('Unittest test discovery adapter', () => { throw e; } }), - typemoq.It.is((options) => { + typeMoq.It.is((options) => { try { assert.deepEqual(options.env, expectedExtraVariables); assert.equal(options.cwd, expectedPath); @@ -122,17 +126,17 @@ suite('Unittest test discovery adapter', () => { } }), ), - typemoq.Times.once(), + typeMoq.Times.once(), ); }); test('DiscoverTests should respect settings.testings.cwd when present', async () => { const expectedNewPath = path.join('/', 'new', 'cwd'); - stubConfigSettings = ({ + configService = ({ getSettings: () => ({ testing: { unittestArgs: ['-v', '-s', '.', '-p', 'test*'], cwd: expectedNewPath.toString() }, }), } as unknown) as IConfigurationService; - const adapter = new UnittestTestDiscoveryAdapter(stubConfigSettings, outputChannel.object); + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); adapter.discoverTests(uri, execFactory.object); const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; @@ -143,7 +147,7 @@ suite('Unittest test discovery adapter', () => { execService.verify( (x) => x.execObservable( - typemoq.It.is>((argsActual) => { + typeMoq.It.is>((argsActual) => { try { assert.equal(argsActual.length, argsExpected.length); assert.deepEqual(argsActual, argsExpected); @@ -153,7 +157,7 @@ suite('Unittest test discovery adapter', () => { throw e; } }), - typemoq.It.is((options) => { + typeMoq.It.is((options) => { try { assert.deepEqual(options.env, expectedExtraVariables); assert.equal(options.cwd, expectedNewPath); @@ -165,7 +169,80 @@ suite('Unittest test discovery adapter', () => { } }), ), - typemoq.Times.once(), + typeMoq.Times.once(), ); }); + test('Test discovery canceled before exec observable call finishes', async () => { + // set up exec mock + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execService.object)); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // Trigger cancellation before exec observable call finishes + cancellationTokenSource.cancel(); + + await discoveryPromise; + + assert.ok( + true, + 'Test resolves correctly when triggering a cancellation token immediately after starting discovery.', + ); + }); + + test('Test discovery cancelled while exec observable is running and proc is closed', async () => { + // + const execService2 = typeMoq.Mock.ofType(); + execService2.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execService2 + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + // Trigger cancellation while exec observable is running + cancellationTokenSource.cancel(); + return { + proc: mockProc as any, + out: new Observable>(), + dispose: () => { + /* no-body */ + }, + }; + }); + // set up exec mock + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService2.object); + }); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // add in await and trigger + await discoveryPromise; + assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.'); + }); });