Repository: nvaccess/nvda

Issue:
Delayed descriptions for characters on cursor movement and Chinese characters
First of all I would like to say, I am not sure if this is a bug, please correct me if I am wrong.


### Steps to reproduce:
1. enabled the function Delayed descriptions for characters on cursor movement of the latest nvda beta, 2023.1 beta1, 
2. open a notepad and type some Chinese words, e.g. 香港
3. I try switching to Chinese Taiwan and Chinese Hong Kong locale to test this function
4. under each of the locale I use  arrow keys to move the edit cursor to check the character descriptions
5. the character description reported by nvda beta is the same for both of the characters in Chinese Taiwan and Chinese Hong Kong locale (where using the enhance phonetic addon switching locale will generate different character description)
6. Under both locale, nvda beta only reported the first available character description of both of the characters here (where using the enhance phonetic addon switching locale will report all of the available character descriptions)

Specifically using the character 港 as an example.
Using the nvda beta here, the character description for 港 is "香港" under hk and tw locale
but using the enhance phonetic reading addon, the character description under hk locale is "港	香港	港澳	港口	海港	商港	軍港	漁港	港區	港務局	轉運港	駐港領事館" and under tw locale is "港	港口	港灣	香港	海港	避風港	高雄港	(左半三點水，右半巷口的巷)"

### Actual behavior:

### Expected behavior:
1. as Chinese locale has different character description under different locale, the character description being reported should be based on the current locale of the nvda
2. I am not sure if it is an intended function of the pull request to only report the first available character description instead of reporting all the available character description of a character, but I would really appreciate the nvda core to retain the function of enhance phonetic reading to report all available character description 

### NVDA logs, crash dumps and other attachments:

### System configuration
#### NVDA installed/portable/running from source:
portable

#### NVDA version:
nvda 2022.3 beta 1
#### Windows version:
windows 10 home ed 21h2
#### Name and version of other software in use when reproducing the issue:

#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your computer?
not tried
#### Have you tried any other versions of NVDA? If so, please report their behaviors.
not tried
#### If NVDA add-ons are disabled, is your problem still occurring?
this is a nvda portable version without any addon
#### Does the issue still occur after you run the COM Registration Fixing Tool in NVDA's tools menu?
not tried


Discussion:

Resolving patch:
diff --git a/source/config/featureFlag.py b/source/config/featureFlag.py
--- a/source/config/featureFlag.py
+++ b/source/config/featureFlag.py
@@ -151,7 +151,6 @@ def _transformSpec_AddFeatureFlagDefault(specString: str, **kwargs) -> str:
 		- 'behaviorOfDefault'
 		- 'optionsEnum'
 	"""
-	log.info(f"specString: {specString}, kwargs: {kwargs}")
 	usage = 'Usage: featureFlag(behaviorOfDefault="enabled"|"disabled", optionsEnum="BoolFlag")'
 	if "default=" in specString:
 		raise VdtParamError(
diff --git a/source/speech/speech.py b/source/speech/speech.py
--- a/source/speech/speech.py
+++ b/source/speech/speech.py
@@ -295,7 +295,13 @@ def _getSpellingSpeechWithoutCharMode(
 		sayCapForCapitals: bool,
 		capPitchChange: int,
 		beepForCapitals: bool,
+		fallbackToCharIfNoDescription: bool = True,
 ) -> Generator[SequenceItemT, None, None]:
+	"""
+	@param fallbackToCharIfNoDescription: Only applies if useCharacterDescriptions is True.
+	If fallbackToCharIfNoDescription is True, and no character description is found,
+	the character itself will be announced. Otherwise, nothing will be spoken.
+	"""
 	
 	defaultLanguage=getCurrentLanguage()
 	if not locale or (not config.conf['speech']['autoDialectSwitching'] and locale.split('_')[0]==defaultLanguage.split('_')[0]):
@@ -309,7 +315,6 @@ def _getSpellingSpeechWithoutCharMode(
 		text=text.rstrip()
 
 	textLength=len(text)
-	count = 0
 	localeHasConjuncts = True if locale.split('_',1)[0] in LANGS_WITH_CONJUNCT_CHARS else False
 	charDescList = getCharDescListFromText(text,locale) if localeHasConjuncts else text
 	for item in charDescList:
@@ -326,6 +331,8 @@ def _getSpellingSpeechWithoutCharMode(
 		if useCharacterDescriptions and charDesc:
 			IDEOGRAPHIC_COMMA = u"\u3001"
 			speakCharAs=charDesc[0] if textLength>1 else IDEOGRAPHIC_COMMA.join(charDesc)
+		elif useCharacterDescriptions and not charDesc and not fallbackToCharIfNoDescription:
+			return None
 		else:
 			speakCharAs=characterProcessing.processSpeechSymbol(locale,speakCharAs)
 		if config.conf['speech']['autoLanguageSwitching']:
@@ -361,23 +368,15 @@ def getSingleCharDescription(
 		capPitchChange = synthConfig["capPitchChange"]
 	else:
 		capPitchChange = 0
-	defaultLanguage = getCurrentLanguage()
-	if not locale or (
-		not config.conf['speech']['autoDialectSwitching']
-		and locale.split('_')[0] == defaultLanguage.split('_')[0]
-	):
-		locale = defaultLanguage
-	# If the description for the locale is unknown, we yield nothing.
-	char, description = getCharDescListFromText(text, locale=locale)[0]
-	uppercase = char.isupper()
-	if description is None:
-		return
 	yield BreakCommand(getSingleCharDescriptionDelayMS())
-	yield from _getSpellingCharAddCapNotification(
-		description[0],
-		sayCapForCapitals=uppercase and synthConfig["sayCapForCapitals"],
-		capPitchChange=(capPitchChange if uppercase else 0),
-		beepForCapitals=uppercase and synthConfig["beepForCapitals"],
+	yield from _getSpellingSpeechWithoutCharMode(
+		text,
+		locale,
+		useCharacterDescriptions=True,
+		sayCapForCapitals=text.isupper() and synthConfig["sayCapForCapitals"],
+		capPitchChange=(capPitchChange if text.isupper() else 0),
+		beepForCapitals=text.isupper() and synthConfig["beepForCapitals"],
+		fallbackToCharIfNoDescription=False,
 	)
 
 


Test patch:
diff --git a/appveyor/scripts/tests/systemTests.ps1 b/appveyor/scripts/tests/systemTests.ps1
--- a/appveyor/scripts/tests/systemTests.ps1
+++ b/appveyor/scripts/tests/systemTests.ps1
@@ -1,6 +1,13 @@
 $testOutput = (Resolve-Path .\testOutput\)
 $systemTestOutput = (Resolve-Path "$testOutput\system")
-.\runsystemtests.bat --variable whichNVDA:installed --variable installDir:"${env:nvdaLauncherFile}" --include installer
+
+.\runsystemtests.bat `
+--variable whichNVDA:installed `
+--variable installDir:"${env:nvdaLauncherFile}" `
+--include installer `
+--include NVDA `
+# last line inentionally blank, allowing all lines to have line continuations.
+
 if($LastExitCode -ne 0) {
 	Set-AppveyorBuildVariable "testFailExitCode" $LastExitCode
 	Add-AppveyorMessage "FAIL: System tests. See test results for more information."
diff --git a/tests/system/libraries/ChromeLib.py b/tests/system/libraries/ChromeLib.py
--- a/tests/system/libraries/ChromeLib.py
+++ b/tests/system/libraries/ChromeLib.py
@@ -16,9 +16,10 @@
 	_getLib,
 )
 from SystemTestSpy.windows import (
+	CloseWindow,
+	GetWindowWithTitle,
 	GetForegroundWindowTitle,
-	GetVisibleWindowTitles,
-	SetForegroundWindow,
+	Window,
 )
 import re
 from robot.libraries.BuiltIn import BuiltIn
@@ -40,31 +41,79 @@ class ChromeLib:
 	_testFileStagingPath = _tempfile.mkdtemp()
 
 	def __init__(self):
-		self.chromeHandle: _Optional[int] = None
+		self.chromeWindow: _Optional[Window] = None
+		"""Chrome Hwnd used to control Chrome via Windows functions."""
+		self.processRFHandleForStart: _Optional[int] = None
+		"""RF process handle, will wait for the chrome process to exit."""
 
 	@staticmethod
 	def _getTestCasePath(filename):
 		return _pJoin(ChromeLib._testFileStagingPath, filename)
 
-	def exit_chrome(self):
+	def close_chrome_tab(self):
 		spy = _NvdaLib.getSpyLib()
+		builtIn.log(
+			# True is expected due to /wait argument.
+			"Is Start process still running (True expected): "
+			f"{process.is_process_running(self.processRFHandleForStart)}"
+		)
 		spy.emulateKeyPress('control+w')
-		process.wait_for_process(self.chromeHandle, timeout="1 minute", on_timeout="continue")
+		process.wait_for_process(
+			self.processRFHandleForStart,
+			timeout="1 minute",
+			on_timeout="continue"
+		)
+		builtIn.log(
+			# False is expected, chrome should have allowed "Start" to exit.
+			"Is Start process still running (False expected): "
+			f"{process.is_process_running(self.processRFHandleForStart)}"
+		)
 
-	def start_chrome(self, filePath):
+	def exit_chrome(self):
+		# When exiting, instance variables cached in other methods may no longer be set.
+		# The RF library may be re-initialised for the teardown procedure.
+		_window = GetWindowWithTitle(
+			re.compile(f"^{ChromeLib._testCaseTitle}"),
+			builtIn.log,
+		)
+		if _window is not None:
+			res = CloseWindow(_window)
+			if not res:
+				builtIn.log(f"Unable to task kill chrome hwnd: {self.chromeWindow.hwndVal}", level="ERROR")
+			else:
+				self.chromeWindow = _window = None
+		else:
+			builtIn.log("No chrome handle, unable to task kill", level="WARN")
+
+	def start_chrome(self, filePath: str, testCase: str) -> Window:
 		builtIn.log(f"starting chrome: {filePath}")
-		self.chromeHandle = process.start_process(
-			"start chrome"
+		self.processRFHandleForStart = process.start_process(
+			"start"  # windows utility to start a process
+			# https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/start
+			" /wait"  # Starts an application and waits for it to end.
+			" chrome"  # Start Chrome
 			" --force-renderer-accessibility"
 			" --suppress-message-center-popups"
 			" --disable-notifications"
-			" -kiosk"
+			" --no-experiments"
+			" --no-default-browser-check"
 			f' "{filePath}"',
 			shell=True,
-			alias='chromeAlias',
+			alias='chromeStartAlias',
+		)
+		process.process_should_be_running(self.processRFHandleForStart)
+		titlePattern = self.getUniqueTestCaseTitleRegex(testCase)
+		success, self.chromeWindow = _blockUntilConditionMet(
+			getValue=lambda: GetWindowWithTitle(titlePattern, lambda message: builtIn.log(message, "DEBUG")),
+			giveUpAfterSeconds=3,
+			shouldStopEvaluator=lambda _window: _window is not None,
+			intervalBetweenSeconds=0.5,
+			errorMessage="Unable to get chrome window"
 		)
-		process.process_should_be_running(self.chromeHandle)
-		return self.chromeHandle
+
+		if not success or self.chromeWindow is None:
+			builtIn.fatal_error("Unable to get chrome window")
+		return self.chromeWindow
 
 	_testCaseTitle = "NVDA Browser Test Case"
 	_beforeMarker = "Before Test Case Marker"
@@ -103,76 +152,94 @@ def _writeTestFile(testCase) -> str:
 			f.write(fileContents)
 		return filePath
 
-	def _wasStartMarkerSpoken(self, speech: str):
-		if "document" not in speech:
-			return False
-		documentIndex = speech.index("document")
-		marker = ChromeLib._beforeMarker
-		return marker in speech and documentIndex < speech.index(marker)
-
-	def _waitForStartMarker(self, spy, lastSpeechIndex):
+	def _waitForStartMarker(self) -> bool:
 		""" Wait until the page loads and NVDA reads the start marker.
-		@param spy:
-		@type spy: SystemTestSpy.speechSpyGlobalPlugin.NVDASpyLib
-		@return: None
+		Depends on Chrome having focus, then tries to ensure that the document is focused and NVDA
+		virtual cursor is set to the "start marker"
+		@return: False on failure
 		"""
-		for i in range(3):  # set a limit on the number of tries.
-			builtIn.sleep("0.5 seconds")  # ensure application has time to receive input
-			spy.wait_for_speech_to_finish()
-			actualSpeech = spy.get_speech_at_index_until_now(lastSpeechIndex)
-			if self._wasStartMarkerSpoken(actualSpeech):
-				break
-			lastSpeechIndex = spy.get_last_speech_index()
-		else:  # Exceeded the number of tries
-			spy.dump_speech_to_log()
-			builtIn.fail(
-				"Unable to locate 'before sample' marker."
-				f" Too many attempts looking for '{ChromeLib._beforeMarker}'"
-				" See NVDA log for full speech."
-			)
+		spy = _NvdaLib.getSpyLib()
+		spy.emulateKeyPress('alt+d')  # focus the address bar, chrome shortcut
+		spy.wait_for_speech_to_finish()
+		addressSpeechIndex = spy.get_last_speech_index()
 
-	def _focusChrome(self, startsWithTestCaseTitle: re.Pattern):
-		""" Ensure chrome started and is focused.
-		Different versions of chrome have variations in how the title is presented.
-		This may mean that there is a separator between document name and application name.
-		E.G. "htmlTest   Google Chrome", "html – Google Chrome" or perhaps no application name at all.
-		Rather than try to get this right, just use the doc title.
-		If this continues to be unreliable we could use Selenium or similar to start chrome and inform us
-		when it is ready.
+		spy.emulateKeyPress('control+F6')  # focus web content, chrome shortcut.
+		spy.wait_for_speech_to_finish()
+		afterControlF6Speech = spy.get_speech_at_index_until_now(addressSpeechIndex)
+		if f"document\n{ChromeLib._beforeMarker}" not in afterControlF6Speech:
+			builtIn.log(afterControlF6Speech, level="DEBUG")
+			return False
+
+		spy.emulateKeyPress('control+home')  # ensure we start at the top of the document
+		controlHomeSpeechIndex = spy.get_last_speech_index()
+		spy.emulateKeyPress('numpad8')  # report current line
+		spy.wait_for_speech_to_finish()
+		afterNumPad8Speech = spy.get_speech_at_index_until_now(controlHomeSpeechIndex)
+		if ChromeLib._beforeMarker not in afterNumPad8Speech:
+			builtIn.log(afterNumPad8Speech, level="DEBUG")
+			return False
+		return True
+
+	def toggleFocusChrome(self) -> None:
+		"""Remove focus, then refocus chrome
+		Attempt to work around NVDA missing focus / foreground events when chrome first opens.
+		Forcing chrome to send another foreground event by focusing the desktop, then using alt+tab to return
+		chrome to the foreground.
+		@remarks If another application raises to the foreground after chrome, this approach won't resolve that
+		situation.
+		We don't have evidence that another application taking focus is a cause of failure yet.
 		"""
-		success, _success = _blockUntilConditionMet(
-			getValue=lambda: SetForegroundWindow(startsWithTestCaseTitle, builtIn.log),
+		spy = _NvdaLib.getSpyLib()
+		spy.emulateKeyPress('windows+d')
+		_blockUntilConditionMet(
 			giveUpAfterSeconds=5,
-			intervalBetweenSeconds=0.2
+			getValue=GetForegroundWindowTitle,
+			shouldStopEvaluator=lambda _title: self.chromeWindow.title != _title,
+			errorMessage="Chrome didn't lose focus"
 		)
-		if success:
-			return
-		windowInformation = ""
-		try:
-			windowInformation = f"Foreground Window: {GetForegroundWindowTitle()}.\n"
-			windowInformation += f"Open Windows: {GetVisibleWindowTitles()}"
-		except OSError as e:
-			builtIn.log(f"Couldn't retrieve active window information.\nException: {e}")
-		raise AssertionError(
-			"Unable to focus Chrome.\n"
-			f"{windowInformation}"
+		spy.emulateKeyPress('alt+tab')
+		_blockUntilConditionMet(
+			giveUpAfterSeconds=5,
+			getValue=GetForegroundWindowTitle,
+			shouldStopEvaluator=lambda _title: self.chromeWindow.title == _title,
+			errorMessage="Chrome didn't gain focus"
 		)
+		spy.wait_for_speech_to_finish()
+
+	def ensureChromeTitleCanBeReported(self, applicationTitle: str) -> int:
+		spy = _NvdaLib.getSpyLib()
+		afterFocusToggleIndex = spy.get_last_speech_index()
+		spy.emulateKeyPress('NVDA+t')
+		appTitleIndex = spy.wait_for_specific_speech(applicationTitle, afterIndex=afterFocusToggleIndex)
+		return appTitleIndex
 
-	def prepareChrome(self, testCase: str) -> None:
+	def prepareChrome(self, testCase: str, _doToggleFocus: bool = False) -> None:
 		"""
 		Starts Chrome opening a file containing the HTML sample
 		@param testCase - The HTML sample to test.
+		@param _doToggleFocus - When True, Chrome will be intentionally de-focused and re-focused
 		"""
 		spy = _NvdaLib.getSpyLib()
+		_chromeLib: "ChromeLib" = _getLib('ChromeLib')  # using the lib gives automatic 'keyword' logging.
 		path = self._writeTestFile(testCase)
 
 		spy.wait_for_speech_to_finish()
 		lastSpeechIndex = spy.get_last_speech_index()
-		self.start_chrome(path)
-		self._focusChrome(ChromeLib.getUniqueTestCaseTitleRegex(testCase))
+		_chromeLib.start_chrome(path, testCase)
 		applicationTitle = ChromeLib.getUniqueTestCaseTitle(testCase)
-		appTitleIndex = spy.wait_for_specific_speech(applicationTitle, afterIndex=lastSpeechIndex)
-		self._waitForStartMarker(spy, appTitleIndex)
+
+		_chromeLib.ensureChromeTitleCanBeReported(applicationTitle)
+		spy.wait_for_speech_to_finish()
+
+		if _doToggleFocus:  # may work around focus/foreground event missed issues for tests.
+			_chromeLib.toggleFocusChrome()
+			spy.wait_for_speech_to_finish()
+
+		if not self._waitForStartMarker():
+			builtIn.fail(
+				"Unable to locate 'before sample' marker."
+				" See NVDA log for full speech."
+			)
 		# Move to the loading status line, and wait fore it to become complete
 		# the page has fully loaded.
 		spy.emulateKeyPress('downArrow')
diff --git a/tests/system/libraries/NotepadLib.py b/tests/system/libraries/NotepadLib.py
--- a/tests/system/libraries/NotepadLib.py
+++ b/tests/system/libraries/NotepadLib.py
@@ -18,7 +18,8 @@
 from SystemTestSpy.windows import (
 	GetForegroundWindowTitle,
 	GetVisibleWindowTitles,
-	SetForegroundWindow,
+	GetForegroundHwnd,
+	GetWindowWithTitle,
 )
 import re
 from robot.libraries.BuiltIn import BuiltIn
@@ -83,11 +84,17 @@ def _writeTestFile(testCase) -> str:
 			f.write(testCase)
 		return filePath
 
-	def _focusNotepad(self, startsWithTestCaseTitle: re.Pattern):
-		""" Ensure Notepad started and is focused.
+	def _waitForNotepadFocus(self, startsWithTestCaseTitle: re.Pattern):
+		""" Wait for Notepad to come into focus.
 		"""
+		def _isNotepadInForeground() -> bool:
+			notepadWindow = GetWindowWithTitle(startsWithTestCaseTitle, builtIn.log)
+			if notepadWindow is None:
+				return False
+			return notepadWindow.hwndVal == GetForegroundHwnd()
+
 		success, _success = _blockUntilConditionMet(
-			getValue=lambda: SetForegroundWindow(startsWithTestCaseTitle, builtIn.log),
+			getValue=_isNotepadInForeground,
 			giveUpAfterSeconds=3,
 			intervalBetweenSeconds=0.5
 		)
@@ -117,7 +124,7 @@ def prepareNotepad(self, testCase: str) -> None:
 
 		spy.wait_for_speech_to_finish()
 		self.start_notepad(path)
-		self._focusNotepad(NotepadLib.getUniqueTestCaseTitleRegex(testCase))
+		self._waitForNotepadFocus(NotepadLib.getUniqueTestCaseTitleRegex(testCase))
 		# Move to the start of file
 		spy.emulateKeyPress('home')
 		spy.wait_for_speech_to_finish()
diff --git a/tests/system/libraries/NvdaLib.py b/tests/system/libraries/NvdaLib.py
--- a/tests/system/libraries/NvdaLib.py
+++ b/tests/system/libraries/NvdaLib.py
@@ -200,7 +200,7 @@ def _startNVDAInstallerProcess(self):
 		)
 		return handle
 
-	def _connectToRemoteServer(self, connectionTimeoutSecs=10):
+	def _connectToRemoteServer(self, connectionTimeoutSecs: int = 15) -> None:
 		"""Connects to the nvdaSpyServer
 		Because we do not know how far through the startup NVDA is, we have to poll
 		to check that the server is available. Importing the library immediately seems
@@ -217,6 +217,7 @@ def _connectToRemoteServer(self, connectionTimeoutSecs=10):
 		_blockUntilConditionMet(
 			getValue=lambda: _testRemoteServer(self._spyServerURI, log=False),
 			giveUpAfterSeconds=connectionTimeoutSecs,
+			intervalBetweenSeconds=0.3,
 			errorMessage=f"Unable to connect to {self._spyAlias}",
 		)
 		builtIn.log(f"Connecting to {self._spyAlias}", level='DEBUG')
@@ -278,10 +279,14 @@ def start_NVDA(self, settingsFileName: str, gesturesFileName: _Optional[str] = N
 		self.lastNVDAStart = _datetime.utcnow()
 		builtIn.log(f"Starting NVDA with config: {settingsFileName}")
 		self.setup_nvda_profile(settingsFileName, gesturesFileName)
+		builtIn.log("Config copied", level="DEBUG")  # observe timing of the startup
 		nvdaProcessHandle = self._startNVDAProcess()
+		builtIn.log("Started NVDA process", level="DEBUG")   # observe timing of the startup
 		process.process_should_be_running(nvdaProcessHandle)
 		self._connectToRemoteServer()
+		builtIn.log("Connected to RF remote server", level="DEBUG")  # observe timing of the startup
 		self.nvdaSpy.wait_for_NVDA_startup_to_complete()
+		builtIn.log("Startup complete", level="DEBUG")  # observe timing of the startup
 		return nvdaProcessHandle
 
 	def save_NVDA_log(self):
diff --git a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py
--- a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py
+++ b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py
@@ -15,6 +15,7 @@
 	Tuple,
 )
 
+import core
 import extensionPoints
 import globalPluginHandler
 import threading
@@ -256,6 +257,9 @@ def get_last_braille_index(self) -> int:
 			return len(self._nvdaBraille_requiresLock) - 1
 
 	def _devInfoToLog(self):
+		"""Should only be called on main thread"""
+		if threading.get_ident() != core.mainThreadId:
+			log.warning("RF lib error, must be called on main thread.")
 		import api
 		obj = api.getNavigatorObject()
 		if hasattr(obj, "devInfo"):
@@ -263,7 +267,10 @@ def _devInfoToLog(self):
 		else:
 			log.info("No developer info for navigator object")
 
-	def dump_speech_to_log(self):
+	def _dump_speech_to_log(self):
+		"""Should only be called on main thread"""
+		if threading.get_ident() != core.mainThreadId:
+			log.warning("RF lib error, must be called on main thread.")
 		log.debug("dump_speech_to_log.")
 		with self._speechLock:
 			try:
@@ -275,7 +282,10 @@ def dump_speech_to_log(self):
 			except Exception:
 				log.error("Unable to log speech")
 
-	def dump_braille_to_log(self):
+	def _dump_braille_to_log(self):
+		"""Should only be called on main thread"""
+		if threading.get_ident() != core.mainThreadId:
+			log.warning("RF lib error, must be called on main thread.")
 		log.debug("dump_braille_to_log.")
 		with self._brailleLock:
 			try:
@@ -283,6 +293,14 @@ def dump_braille_to_log(self):
 			except Exception:
 				log.error("Unable to log braille")
 
+	def dump_speech_to_log(self):
+		# must be called on mainThread queue that to happen
+		core.callLater(0, self._dump_speech_to_log)
+
+	def dump_braille_to_log(self):
+		# must be called on mainThread queue that to happen
+		core.callLater(0, self._dump_speech_to_log)
+
 	def _minTimeout(self, timeout: float) -> float:
 		"""Helper to get the minimum value, the timeout passed in, or self._maxKeywordDuration"""
 		return min(timeout, self._maxKeywordDuration)
@@ -493,6 +511,11 @@ def terminate(self):
 
 
 def _crashNVDA():
+	# Causes a breakpoint exception to occur in the current process.
+	# This allows the calling thread to signal the debugger to handle the exception.
+	#
+	# This may be caught by a "postmortem debugger", which would prevent the application from exiting.
+	# https://docs.microsoft.com/en-us/windows-hardware/drivers/debugger/enabling-postmortem-debugging
 	ctypes.windll.Kernel32.DebugBreak()
 
 
diff --git a/tests/system/libraries/SystemTestSpy/windows.py b/tests/system/libraries/SystemTestSpy/windows.py
--- a/tests/system/libraries/SystemTestSpy/windows.py
+++ b/tests/system/libraries/SystemTestSpy/windows.py
@@ -7,18 +7,38 @@
 """
 
 from ctypes.wintypes import HWND, LPARAM
-from ctypes import c_bool, c_int, create_unicode_buffer, POINTER, WINFUNCTYPE, windll, WinError
+from ctypes import (
+	c_bool,
+	c_int,
+	create_unicode_buffer,
+	POINTER,
+	WINFUNCTYPE,
+	windll,
+	WinError,
+)
+from typing import (
+	Callable,
+	List,
+	NamedTuple,
+	Optional,
+)
 import re
 from SystemTestSpy.blockUntilConditionMet import _blockUntilConditionMet
-from typing import Callable, List, NamedTuple
+from robot.libraries.BuiltIn import BuiltIn
+
+builtIn: BuiltIn = BuiltIn()
+
+# rather than using the ctypes.c_void_p type, which may encourage attempting to dereference
+# what may be an invalid or illegal pointer, we'll treat it as an opaque value.
+HWNDVal = int
 
 
 class Window(NamedTuple):
-	hwnd: HWND
+	hwndVal: HWNDVal
 	title: str
 
 
-def _GetWindowTitle(hwnd: HWND) -> str:
+def _GetWindowTitle(hwnd: HWNDVal) -> str:
 	length = windll.user32.GetWindowTextLengthW(hwnd)
 	if not length:
 		return ''
@@ -32,41 +52,76 @@ def _GetWindows(
 		filterUsingWindow: Callable[[Window], bool] = lambda _: True,
 ) -> List[Window]:
 	windows: List[Window] = []
-	EnumWindowsProc = WINFUNCTYPE(c_bool, POINTER(c_int), POINTER(c_int))
 
-	def _append_title(hwnd: HWND, _lParam: LPARAM) -> bool:
+	# BOOL CALLBACK EnumWindowsProc _In_ HWND,_In_ LPARAM
+	# HWND as a pointer creates confusion, treat as an int
+	# http://makble.com/the-story-of-lpclong
+	@WINFUNCTYPE(c_bool, c_int, POINTER(c_int))
+	def _append_title(hwnd: HWNDVal, _lParam: LPARAM) -> bool:
+		if not isinstance(hwnd, (HWNDVal, c_int)):
+			builtIn.log(f"Hwnd type {type(hwnd)}, value {hwnd}")
 		window = Window(hwnd, _GetWindowTitle(hwnd))
 		if filterUsingWindow(window):
 			windows.append(window)
 		return True
 
-	if not windll.user32.EnumWindows(EnumWindowsProc(_append_title), 0):
+	if not windll.user32.EnumWindows(_append_title, 0):
 		raise WinError()
 	return windows
 
 
 def _GetVisibleWindows() -> List[Window]:
 	return _GetWindows(
-		filterUsingWindow=lambda window: windll.user32.IsWindowVisible(window.hwnd) and bool(window.title)
+		filterUsingWindow=lambda window: windll.user32.IsWindowVisible(window.hwndVal) and bool(window.title)
 	)
 
 
-def SetForegroundWindow(targetTitle: re.Pattern, logger: Callable[[str], None] = lambda _: None) -> bool:
-	currentTitle = GetForegroundWindowTitle()
-	if re.match(targetTitle, currentTitle):
-		logger(f"Window '{currentTitle}' already focused")
+def CloseWindow(window: Window) -> bool:
+	"""
+	@return: True if the window exists and the message was sent.
+	"""
+	if windowWithHandleExists(window.hwndVal):
+		return windll.user32.CloseWindow(
+			window.hwndVal,
+		)
+	return False
+
+
+Logger = Callable[[str], None]
+
+
+def SetForegroundWindow(window: Window, logger: Logger) -> bool:
+	"""This may be unreliable, there are conditions on which processes can set the foreground window.
+	https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
+	Additionally, note that this code is run by the Robot Framework test runner, not NVDA.
+	"""
+	assert window is not None
+	assert bool(window.hwndVal)
+
+	if window.hwndVal == GetForegroundHwnd():
+		title = _GetWindowTitle(window.hwndVal)
+		logger(f"Window already focused, HWND '{window.hwndVal}' with title: {title}")
 		return True
+
+	logger(f"Focusing window to (HWND: {window.hwndVal}) (title: {window.title})")
+	# https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
+	# The test may not be able to set the foreground window with user32.SetForegroundWindow
+	return windll.user32.SetForegroundWindow(window.hwndVal)
+
+
+def GetWindowWithTitle(targetTitle: re.Pattern, logger: Logger) -> Optional[Window]:
 	windows = _GetWindows(
-		filterUsingWindow=lambda window: re.match(targetTitle, window.title)
+		filterUsingWindow=lambda _window: bool(re.match(targetTitle, _window.title))
 	)
 	if len(windows) == 1:
-		logger(f"Focusing window to (HWND: {windows[0].hwnd}) (title: {windows[0].title})")
-		return windll.user32.SetForegroundWindow(windows[0].hwnd)
+		logger(f"Found window (HWND: {windows[0].hwndVal}) (title: {windows[0].title})")
+		return windows[0]
 	elif len(windows) == 0:
-		logger("No windows matching the pattern found")
+		logger(f"No windows found matching the pattern: {targetTitle}")
+		return None
 	else:
 		logger(f"Too many windows to focus {windows}")
-	return False
+		return None
 
 
 def GetVisibleWindowTitles() -> List[str]:
@@ -74,8 +129,12 @@ def GetVisibleWindowTitles() -> List[str]:
 	return [w.title for w in windows]
 
 
+def GetForegroundHwnd() -> HWNDVal:
+	return windll.user32.GetForegroundWindow()
+
+
 def GetForegroundWindowTitle() -> str:
-	hwnd = windll.user32.GetForegroundWindow()
+	hwnd: HWNDVal = windll.user32.GetForegroundWindow()
 	return _GetWindowTitle(hwnd)
 
 
@@ -87,9 +146,9 @@ def waitUntilWindowFocused(targetWindowTitle: str, timeoutSecs: int = 5):
 	)
 
 
-def getWindowHandle(windowClassName: str, windowName: str) -> int:
+def getWindowHandle(windowClassName: str, windowName: str) -> HWNDVal:
 	return windll.user32.FindWindowW(windowClassName, windowName)
 
 
-def windowWithHandleExists(handle: int) -> bool:
+def windowWithHandleExists(handle: HWNDVal) -> bool:
 	return bool(windll.user32.IsWindow(handle))
diff --git a/tests/system/readme.md b/tests/system/readme.md
--- a/tests/system/readme.md
+++ b/tests/system/readme.md
@@ -7,17 +7,20 @@ Dependencies such as Robot are automatically installed for you when NVDA's build
  
 ### Running the tests
 
-You can run the tests with `runsystemtests.bat`.
-Running this script with no arguments will run all system tests found in tests\system\robot, against the current source copy of NVDA.
+You can run the tests with `runsystemtests.bat --include <TAG>`.
+This will run against the source copy of NVDA.
 Any extra arguments provided to this script are forwarded on to Robot.
+**Note:** For tests to run the [tags to include **must** be specified](#tags-are-required).
+Include can be specified multiple times, acting as a logical OR.
 
 To run a single test, add the `--test` argument (wildcards accepted).
 
 ```
-runsystemtests --test "starts" ...
+runsystemtests --include chrome --test "starts" ...
 ```
 
-To run all tests with a particular tag use `-i`:
+A shorter alternative for `--include` is `-i`.
+E.G. to run all tests with the chrome tag use:
 ```
 runsystemtests -i "chrome" ...
 ```
@@ -25,6 +28,21 @@ runsystemtests -i "chrome" ...
 Other options exit for specifying tests to run (e.g. by suite, tag, etc).
 Consult `runsystemtests --help`
 
+### Tags are required
+
+Running this script with no arguments won't run any tests, instead an error will be given:
+```
+[ ERROR ] Suite 'Robot' contains no tests matching tag 'fakeTagToEnforceUsageOfInclude' and not matching tag 'excluded from build'.
+```
+This is to prevent accidental running of the installer tests.
+Instead, the tags should be explicitly included, to run all (except installer) tests.
+Examples:
+- All tests tagged with NVDA: `runsystemtests.bat --include NVDA`
+- All Chrome tests: `runsystemtests.bat --include chrome`
+
+This is implemented by supplying an unused tag `fakeTagToEnforceUsageOfInclude` to RobotFramework via the
+`tests\system\robotArgs.robot` file.
+
 ### Getting the results
 
 The process is displayed in the command prompt, for more information consider the [Robot report and NVDA logs](#logs)
diff --git a/tests/system/robot/chromeTests.robot b/tests/system/robot/chromeTests.robot
--- a/tests/system/robot/chromeTests.robot
+++ b/tests/system/robot/chromeTests.robot
@@ -21,7 +21,6 @@ default teardown
 	Run Keyword If Test Failed	Take Screenshot	${screenShotName}
 	dump_speech_to_log
 	dump_braille_to_log
-	exit chrome
 	quit NVDA
 
 default setup
diff --git a/tests/system/robotArgs.robot b/tests/system/robotArgs.robot
--- a/tests/system/robotArgs.robot
+++ b/tests/system/robotArgs.robot
@@ -2,7 +2,7 @@
 --outputdir testOutput\system
 --xunit systemTests.xml
 --pythonpath .\tests\system\libraries
---include NVDA
 --exclude excluded_from_build
+--include fakeTagToEnforceUsageOfInclude
 --variable whichNVDA:source
 --variable installDir: