Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 4 additions & 1 deletion .github/workflows/testAndPublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ jobs:
fail-fast: false
matrix:
# To skip tests, and just run install, replace with [excluded_from_build]
testSuite: [chrome, installer, startupShutdown, symbols]
testSuite: [chrome, installer, startupShutdown, symbols, vscode]
runner: ${{ fromJson(needs.matrix.outputs.supportedRunners) }}
arch: ${{ fromJson(needs.matrix.outputs.supportedArchitectures) }}
pythonVersion: ${{ fromJson(needs.matrix.outputs.supportedPythonVersions) }}
Expand Down Expand Up @@ -430,6 +430,9 @@ jobs:
run: ci/scripts/installNVDA.ps1
env:
nvdaLauncherDir: ${{ steps.getLauncher.outputs.download-path }}
- name: Download and install Visual Studio Code
if: ${{ matrix.testSuite == 'vscode' }}
run: choco install -y vscode
- name: Run system tests
run: ci/scripts/tests/systemTests.ps1
env:
Expand Down
88 changes: 87 additions & 1 deletion source/appModules/code.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2020-2025 NV Access Limited, Leonard de Ruijter, Cary-Rowen
# Copyright (C) 2020-2025 NV Access Limited, Leonard de Ruijter, Cary-Rowen, Bill Dengler
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

"""App module for Visual Studio Code."""

import api
import appModuleHandler
import controlTypes
import re
from collections import deque
from NVDAObjects.behaviors import EditableTextBase
from NVDAObjects.IAccessible.chromium import Document
from NVDAObjects import NVDAObject, NVDAObjectTextInfo
Expand All @@ -21,11 +24,92 @@ class VSCodeDocument(Document):
_get_treeInterceptorClass = NVDAObject._get_treeInterceptorClass


DIGIT_EXPR = re.compile(r"\d+")


class AppModule(appModuleHandler.AppModule):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._status = None

@staticmethod
def _search_for_statusbar(root: NVDAObject) -> NVDAObject | None:
seen = set()
t = deque((root,))
while t:
obj = t.popleft()
if obj in seen:
continue
seen.add(obj)
try:
if obj.role == controlTypes.Role.STATUSBAR:
return obj
# IA2 ID often contains "statusbar"
ia2id = obj.IA2Attributes.get("id")
if ia2id and "statusbar" in ia2id.casefold():
return obj
children = obj.children
except Exception:
children = ()
t.extend(children)
return None

@staticmethod
def _looks_like_line_col(text: str) -> bool:
"""
Detect two integers separated by something that is NOT a dot,
to avoid version-like strings.
"""
it = DIGIT_EXPR.finditer(text)
first = next(it, None)
if first is None:
return False
second = next(it, None)
if second is None:
return False
between = text[first.end() : second.start()]
if "." in between:
return False
if not between.strip():
# Only whitespace or an empty string found,
# not the line/column number
return False
return True

def _get_statusBar(self) -> NVDAObject:
cached = self._status
if cached:
return cached

# Fallback: search the current foreground window tree for a STATUSBAR.
foreground = api.getForegroundObject()
res = self._search_for_statusbar(foreground)
if res:
self._status = res
return res
raise NotImplementedError

def chooseNVDAObjectOverlayClasses(self, obj, clsList):
if Document in clsList and obj.IA2Attributes.get("tag") == "#document":
clsList.insert(0, VSCodeDocument)

def getStatusBarText(self, obj: NVDAObject) -> str:
parts: list[str] = [
chunk
for child in obj.children
for label in (child.name, child.value)
if label and (chunk := label.strip())
]

if not parts:
raise NotImplementedError

pos_idx = next((i for i, e in enumerate(parts) if self._looks_like_line_col(e)), None)
if pos_idx is not None and pos_idx > 0:
# Move line and column to the start for speech-friendliness
parts.insert(0, parts.pop(pos_idx))
return " ".join(parts)

def event_NVDAObject_init(self, obj: NVDAObject):
if isinstance(obj, EditableTextBase):
obj._supportsSentenceNavigation = False
Expand All @@ -34,3 +118,5 @@ def event_NVDAObject_init(self, obj: NVDAObject):
# See issue #15159 for more details.
if obj.role != controlTypes.Role.EDITABLETEXT and controlTypes.State.EDITABLE not in obj.states:
obj.TextInfo = NVDAObjectTextInfo
if obj.role == controlTypes.Role.STATUSBAR:
self._status = obj
14 changes: 7 additions & 7 deletions tests/system/libraries/SystemTestSpy/windows.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2021-2025 NV Access Limited, Łukasz Golonka
# Copyright (C) 2021-2025 NV Access Limited, Łukasz Golonka, Bill Dengler
# This file may be used under the terms of the GNU General Public License, version 2 or later.
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html

Expand Down Expand Up @@ -75,14 +75,14 @@ def _GetVisibleWindows() -> list[Window]:

def CloseWindow(window: Window) -> bool:
"""
@return: True if the window exists and the message was sent.
Request that the target window close gracefully (WM_CLOSE).
:return: True if the window exists and the message was posted.
"""
if windowWithHandleExists(window.hwndVal):
return bool(
windll.user32.CloseWindow(
window.hwndVal,
),
)
# We do not use user32.CloseWindow, which minimizes (rather than
# closing) the window.
WM_CLOSE: int = 0x0010
return bool(windll.user32.PostMessageW(window.hwndVal, WM_CLOSE, 0, 0))
return False


Expand Down
184 changes: 184 additions & 0 deletions tests/system/libraries/VSCodeLib.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2025 Bill Dengler
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

import os as _os
import re as _re
import shutil as _shutil
import tempfile as _tempfile
import json as _json
from robot.libraries.BuiltIn import BuiltIn as _BuiltIn
from SystemTestSpy import _blockUntilConditionMet, _getLib
from SystemTestSpy.windows import (
CloseWindow as _CloseWindow,
GetWindowWithTitle as _GetWindowWithTitle,
SetForegroundWindow as _SetForegroundWindow,
Window as _Window,
windowWithHandleExists as _windowWithHandleExists,
)
import NvdaLib as _NvdaLib

from robot.libraries.Process import Process as _ProcessLib
import WindowsLib as _WindowsLib

_builtIn: _BuiltIn = _BuiltIn()
_process: _ProcessLib = _getLib("Process")
_windowsLib: _WindowsLib = _getLib("WindowsLib")

_LAUNCHER_PATHS = [
_os.path.expandvars(r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe"),
_os.path.expandvars(r"C:\Program Files\Microsoft VS Code\Code.exe"),
_os.path.expandvars(r"%ProgramFiles%\Microsoft VS Code\Code.exe"),
_os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft VS Code\Code.exe"),
]
_LAUNCHER_CMDS = [
_os.path.expandvars(r"%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd"),
"code",
]
_WINDOW_TITLE_PATTERN = _re.compile(r"Visual Studio Code", _re.IGNORECASE)


class VSCodeLib:
_testTempDir: str | None = None
_codeWindow: _Window | None = None
_processRFHandleForStart: int | None = None

@staticmethod
def _findCodeLauncher() -> str:
for p in _LAUNCHER_PATHS:
if _os.path.isfile(p):
return f'"{p}"'
for candidate in _LAUNCHER_CMDS:
resolved = _shutil.which(candidate)
if resolved:
return f'"{resolved}"'
raise AssertionError("Visual Studio Code launcher not found. Is it installed?")

def start_vscode(self) -> _Window:
launcher = self._findCodeLauncher()
if VSCodeLib._testTempDir is None:
VSCodeLib._testTempDir = _tempfile.mkdtemp(prefix="nvdatest")
userDataDir = _os.path.join(VSCodeLib._testTempDir, "vscodeUserData")
_os.makedirs(userDataDir, exist_ok=True)

# Prepare user settings to suppress welcome/startup screen
userSettingsDir = _os.path.join(userDataDir, "User")
_os.makedirs(userSettingsDir, exist_ok=True)
settingsPath = _os.path.join(userSettingsDir, "settings.json")
try:
if not _os.path.isfile(settingsPath):
with open(settingsPath, "w", encoding="utf-8") as cam:
_json.dump(
{
"editor.accessibilitySupport": "on",
"workbench.startupEditor": "none",
"update.showReleaseNotes": False,
"workbench.tips.enabled": False,
"update.mode": "none",
"telemetry.telemetryLevel": "off",
},
cam,
ensure_ascii=False,
indent=2,
)
except Exception as e:
_builtIn.log(
f"Failed to prepare Visual Studio Code settings to skip welcome screen: {e!r}",
level="WARN",
)

cmd = (
f'start "" /wait {launcher} '
f'--user-data-dir "{userDataDir}" '
f"--disable-gpu "
f"--disable-extensions "
f"--disable-workspace-trust "
f"--skip-add-to-recently-opened "
f"-n "
f"--wait"
)
_builtIn.log(f"Starting Visual Studio Code: {cmd}", level="DEBUG")
VSCodeLib._processRFHandleForStart = _process.start_process(
cmd,
shell=True,
alias="vscodeStartAlias",
)

success, VSCodeLib._codeWindow = _blockUntilConditionMet(
getValue=lambda: _GetWindowWithTitle(
_WINDOW_TITLE_PATTERN,
lambda m: _builtIn.log(m, level="DEBUG"),
),
giveUpAfterSeconds=15.0,
shouldStopEvaluator=lambda w: w is not None,
intervalBetweenSeconds=0.5,
errorMessage="Unable to get Visual Studio Code window",
)
if not success or VSCodeLib._codeWindow is None:
_builtIn.fatal_error("Unable to get Visual Studio Code window")

_windowsLib.taskSwitchToItemMatching(targetWindowNamePattern=_WINDOW_TITLE_PATTERN)
_windowsLib.logForegroundWindowTitle()
_NvdaLib.getSpyLib().wait_for_speech_to_finish()
return VSCodeLib._codeWindow

def close_vscode(self):
window = VSCodeLib._codeWindow or _GetWindowWithTitle(
_WINDOW_TITLE_PATTERN,
lambda m: _builtIn.log(m, level="DEBUG"),
)
if not window:
_builtIn.log("No Visual Studio Code window handle to close.", level="WARN")
return

_CloseWindow(window)
success, _ = _blockUntilConditionMet(
getValue=lambda: not _windowWithHandleExists(window.hwndVal),
giveUpAfterSeconds=10.0,
shouldStopEvaluator=lambda x: bool(x),
intervalBetweenSeconds=0.5,
)

if not success:
_builtIn.log("Window still present after WM_CLOSE, trying Alt+F4.", level="WARN")
try:
_SetForegroundWindow(window, lambda m: _builtIn.log(m, level="DEBUG"))
except Exception:
pass
try:
_NvdaLib.getSpyLib().emulateKeyPress("alt+F4")
except Exception:
pass
# Now wait again, failing the test if the window is still present.
_blockUntilConditionMet(
getValue=lambda: not _windowWithHandleExists(window.hwndVal),
giveUpAfterSeconds=15.0,
shouldStopEvaluator=lambda x: bool(x),
intervalBetweenSeconds=0.5,
errorMessage="Visual Studio Code window did not close after sending close events.",
)

if VSCodeLib._processRFHandleForStart:
_process.wait_for_process(
VSCodeLib._processRFHandleForStart,
timeout="10 seconds",
on_timeout="continue",
)
VSCodeLib._processRFHandleForStart = None

try:
if VSCodeLib._testTempDir and _os.path.isdir(VSCodeLib._testTempDir):
_builtIn.log(
f"Cleaning up Visual Studio Code temp dir: {VSCodeLib._testTempDir}",
level="DEBUG",
)
_shutil.rmtree(VSCodeLib._testTempDir)
except Exception as e:
_builtIn.log(
f"Failed to remove temp dir '{VSCodeLib._testTempDir}': {e!r}",
level="WARN",
)
finally:
VSCodeLib._testTempDir = None
VSCodeLib._codeWindow = None
19 changes: 19 additions & 0 deletions tests/system/robot/vscodeTests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2025 Bill Dengler
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.


from robot.libraries.BuiltIn import BuiltIn
from SystemTestSpy import _getLib
import NvdaLib as _NvdaLib

_builtIn: BuiltIn = BuiltIn()
_vscode = _getLib("VSCodeLib")


def vs_code_status_line_is_available():
"""Start Visual Studio Code and ensure NVDA+end does not report "no status line found"."""
_vscode.start_vscode()
speech = _NvdaLib.getSpeechAfterKey("NVDA+end")
_builtIn.should_not_contain(speech, "no status line found")
Loading
Loading