Skip to content

Commit 917324f

Browse files
committed
test: eliminate popup-based test failures
1 parent 0d6dace commit 917324f

16 files changed

Lines changed: 302 additions & 10 deletions

Build/scripts/Invoke-CppTest.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ Initialize-VsDevEnvironment
8484
# Suppress assertion dialog boxes (DebugProcs.dll checks this env var)
8585
# This prevents tests from blocking on MessageBox popups
8686
$env:AssertUiEnabled = 'false'
87+
# Unconditional test-mode override: bypasses registry AssertMessageBox key in DebugProcs.dll
88+
$env:FW_TEST_MODE = '1'
8789

8890
# Suppress Windows Error Reporting and crash dialogs
8991
# SEM_FAILCRITICALERRORS = 0x0001

Src/AppForTests.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<trace autoflush="false" indentsize="4">
55
<listeners>
66
<clear/>
7+
<add name="ConsoleTraceListener" type="System.Diagnostics.ConsoleTraceListener"/>
78
<add name="FwTraceListener" type="SIL.LCModel.Utils.EnvVarTraceListener, SIL.LCModel.Utils, Version=11.0.0.0, Culture=neutral"
89
initializeData="assertuienabled='false' assertexceptionenabled='true' logfilename='%temp%/asserts.log'"/>
910
</listeners>

Src/AssemblyInfoForTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
// Set stub for messagebox so that we don't pop up a message box when running tests.
1717
[assembly: SetMessageBoxAdapter]
1818

19+
// Log last-chance managed exceptions to console output before process termination.
20+
[assembly: LogUnhandledExceptions]
21+
22+
// Suppress all assertion dialog boxes (native + managed) regardless of config file coverage
23+
[assembly: SuppressAssertDialogs]
24+
1925
// Cleanup all singletons after running tests
2026
[assembly: CleanupSingletons]
2127

Src/AssemblyInfoForUiIndependentTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
// This file is for test fixtures for UI independent projects, i.e. projects that don't
1111
// reference System.Windows.Forms et al.
1212

13+
// Log last-chance managed exceptions to console output before process termination.
14+
[assembly: LogUnhandledExceptions]
15+
1316
// Cleanup all singletons after running tests
1417
[assembly: CleanupSingletons]
1518

Src/Common/FwUtils/FwUtilsTests/Attributes/HandleApplicationThreadExceptionAttribute.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ public override void AfterTest(ITest test)
4242

4343
private void OnThreadException(object sender, ThreadExceptionEventArgs e)
4444
{
45+
Console.Error.WriteLine("Unhandled Windows Forms thread exception during test run:");
46+
Console.Error.WriteLine(e.Exception.ToString());
47+
Console.Error.Flush();
48+
4549
throw new ApplicationException(e.Exception.Message, e.Exception);
4650
}
4751
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.Threading.Tasks;
7+
using NUnit.Framework;
8+
using NUnit.Framework.Interfaces;
9+
10+
namespace SIL.FieldWorks.Common.FwUtils.Attributes
11+
{
12+
/// <summary>
13+
/// Assembly-level test bootstrap that logs last-chance managed exceptions to the
14+
/// console so unattended test runs always have readable failure details.
15+
///
16+
/// This is intentionally a logging-only hook. It does not try to recover or keep
17+
/// the process alive after an unhandled exception.
18+
/// </summary>
19+
[AttributeUsage(AttributeTargets.Assembly)]
20+
public class LogUnhandledExceptionsAttribute : TestActionAttribute
21+
{
22+
private UnhandledExceptionEventHandler m_unhandledExceptionHandler;
23+
private EventHandler<UnobservedTaskExceptionEventArgs> m_unobservedTaskExceptionHandler;
24+
25+
/// <summary/>
26+
public override ActionTargets Targets => ActionTargets.Suite;
27+
28+
/// <summary/>
29+
public override void BeforeTest(ITest test)
30+
{
31+
base.BeforeTest(test);
32+
33+
m_unhandledExceptionHandler = OnUnhandledException;
34+
AppDomain.CurrentDomain.UnhandledException += m_unhandledExceptionHandler;
35+
36+
m_unobservedTaskExceptionHandler = OnUnobservedTaskException;
37+
TaskScheduler.UnobservedTaskException += m_unobservedTaskExceptionHandler;
38+
}
39+
40+
/// <summary/>
41+
public override void AfterTest(ITest test)
42+
{
43+
if (m_unhandledExceptionHandler != null)
44+
{
45+
AppDomain.CurrentDomain.UnhandledException -= m_unhandledExceptionHandler;
46+
m_unhandledExceptionHandler = null;
47+
}
48+
49+
if (m_unobservedTaskExceptionHandler != null)
50+
{
51+
TaskScheduler.UnobservedTaskException -= m_unobservedTaskExceptionHandler;
52+
m_unobservedTaskExceptionHandler = null;
53+
}
54+
55+
base.AfterTest(test);
56+
}
57+
58+
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
59+
{
60+
Console.Error.WriteLine("Unhandled managed exception during test run:");
61+
Console.Error.WriteLine($"IsTerminating: {e.IsTerminating}");
62+
Console.Error.WriteLine(e.ExceptionObject?.ToString() ?? "<null exception object>");
63+
Console.Error.Flush();
64+
}
65+
66+
private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
67+
{
68+
Console.Error.WriteLine("Unobserved task exception during test run:");
69+
Console.Error.WriteLine(e.Exception.ToString());
70+
Console.Error.Flush();
71+
72+
// Escalate instead of allowing the exception to be quietly ignored at finalization time.
73+
throw new UnobservedTaskExceptionLoggedException(e.Exception);
74+
}
75+
}
76+
77+
/// <summary>
78+
/// Exception used to surface unobserved task failures in test output after the original
79+
/// AggregateException has been written to the console.
80+
/// </summary>
81+
public class UnobservedTaskExceptionLoggedException : Exception
82+
{
83+
public UnobservedTaskExceptionLoggedException(AggregateException innerException)
84+
: base("Unobserved task exception during test run.", innerException)
85+
{
86+
}
87+
}
88+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 SIL International
2+
// This software is licensed under the LGPL, version 2.1 or later
3+
// (http://www.gnu.org/licenses/lgpl-2.1.html)
4+
5+
using System;
6+
using System.Diagnostics;
7+
using NUnit.Framework;
8+
using NUnit.Framework.Interfaces;
9+
10+
namespace SIL.FieldWorks.Common.FwUtils.Attributes
11+
{
12+
/// <summary>
13+
/// NUnit assembly-level attribute that suppresses all assertion dialog boxes during tests.
14+
/// Sets environment variables that DebugProcs.dll (native) and EnvVarTraceListener (managed)
15+
/// honor, and ensures debug trace/assert output is mirrored to the console.
16+
///
17+
/// For test projects that link AppForTests.config, EnvVarTraceListener already handles
18+
/// assert-to-exception conversion and file logging. For the remaining projects, this
19+
/// attribute installs a listener that converts Debug.Fail into test failures.
20+
/// </summary>
21+
[AttributeUsage(AttributeTargets.Assembly)]
22+
public class SuppressAssertDialogsAttribute : TestActionAttribute
23+
{
24+
private ConsoleErrorTraceListener m_listener;
25+
26+
/// <summary/>
27+
public override ActionTargets Targets => ActionTargets.Suite;
28+
29+
/// <summary/>
30+
public override void BeforeTest(ITest test)
31+
{
32+
base.BeforeTest(test);
33+
34+
// Force environment variables that control native (DebugProcs.dll) and
35+
// managed (EnvVarTraceListener) assertion behavior.
36+
Environment.SetEnvironmentVariable("AssertUiEnabled", "false");
37+
Environment.SetEnvironmentVariable("AssertExceptionEnabled", "true");
38+
Environment.SetEnvironmentVariable("FW_TEST_MODE", "1");
39+
40+
// If EnvVarTraceListener is already installed (via AppForTests.config), keep
41+
// its assert-to-exception and file logging behavior, but still mirror output
42+
// to the console. Otherwise, our listener is responsible for failing tests.
43+
bool hasEnvVarListener = false;
44+
foreach (TraceListener listener in Trace.Listeners)
45+
{
46+
// Check by type name to avoid a hard dependency on SIL.LCModel.Utils.
47+
if (listener.GetType().Name == "EnvVarTraceListener")
48+
{
49+
hasEnvVarListener = true;
50+
break;
51+
}
52+
}
53+
54+
// Suppress the DefaultTraceListener dialog even when another listener is
55+
// present so assertions never degrade back to modal UI.
56+
foreach (TraceListener listener in Trace.Listeners)
57+
{
58+
if (listener is DefaultTraceListener dtl)
59+
{
60+
dtl.AssertUiEnabled = false;
61+
break;
62+
}
63+
}
64+
65+
m_listener = new ConsoleErrorTraceListener(throwOnFail: !hasEnvVarListener);
66+
Trace.Listeners.Insert(0, m_listener);
67+
}
68+
69+
/// <summary/>
70+
public override void AfterTest(ITest test)
71+
{
72+
if (m_listener != null)
73+
{
74+
Trace.Listeners.Remove(m_listener);
75+
m_listener = null;
76+
}
77+
78+
base.AfterTest(test);
79+
}
80+
}
81+
82+
/// <summary>
83+
/// Mirrors debug trace output to Console.Error and optionally converts
84+
/// Debug.Fail/failed Debug.Assert calls into exceptions.
85+
/// </summary>
86+
internal class ConsoleErrorTraceListener : TraceListener
87+
{
88+
private readonly bool m_throwOnFail;
89+
90+
public ConsoleErrorTraceListener(bool throwOnFail)
91+
{
92+
m_throwOnFail = throwOnFail;
93+
}
94+
95+
public override void Fail(string message)
96+
{
97+
WriteFailure(message, null);
98+
}
99+
100+
public override void Fail(string message, string detailMessage)
101+
{
102+
WriteFailure(message, detailMessage);
103+
}
104+
105+
public override void Write(string message)
106+
{
107+
Console.Error.Write(message);
108+
Console.Error.Flush();
109+
}
110+
111+
public override void WriteLine(string message)
112+
{
113+
Console.Error.WriteLine(message);
114+
Console.Error.Flush();
115+
}
116+
117+
private void WriteFailure(string message, string detailMessage)
118+
{
119+
var full = string.IsNullOrEmpty(detailMessage)
120+
? message
121+
: $"{message}{Environment.NewLine}{detailMessage}";
122+
123+
Console.Error.WriteLine("Debug.Fail/Assert fired during test:");
124+
Console.Error.WriteLine(full);
125+
Console.Error.Flush();
126+
127+
if (m_throwOnFail)
128+
throw new AssertionDialogException(full);
129+
}
130+
}
131+
132+
/// <summary>
133+
/// Exception thrown when a Debug.Fail or Debug.Assert fires during a test,
134+
/// replacing the modal Abort/Retry/Ignore dialog with a clear test failure.
135+
/// </summary>
136+
public class AssertionDialogException : Exception
137+
{
138+
public AssertionDialogException(string message)
139+
: base($"Debug.Fail/Assert fired during test (would have shown a modal dialog):\n{message}")
140+
{
141+
}
142+
}
143+
}

Src/DebugProcs/DebugProcs.cpp

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,29 @@ extern "C" __declspec(dllexport) int APIENTRY DebugProcsExit(void)
286286

287287
/*----------------------------------------------------------------------------------------------
288288
Returns the AssertMessageBox value from the registry; if not set return the value of the
289-
environment variable AssertUiEnabled; if not set return true
289+
environment variable AssertUiEnabled; if not set return true.
290+
FW_TEST_MODE=1 takes absolute priority and always returns false (no dialog).
290291
----------------------------------------------------------------------------------------------*/
291292
bool GetShowAssertMessageBox()
292293
{
294+
// getenv is deprecated on Windows
295+
#if defined(WIN32) || defined(WIN64)
296+
#pragma warning(push)
297+
#pragma warning(disable: 4996)
298+
299+
// Windows doesn't know strcasecmp, it calls it stricmp instead...
300+
#ifndef strcasecmp
301+
#define strcasecmp stricmp
302+
#endif
303+
#endif // WIN32
304+
305+
// FW_TEST_MODE is an unconditional override set by test runners.
306+
// It takes priority over both the registry and AssertUiEnabled so that
307+
// developer machines with the registry key set never pop dialogs during tests.
308+
const char* pTestMode = getenv("FW_TEST_MODE");
309+
if (pTestMode && strcasecmp(pTestMode, "1") == 0)
310+
return false;
311+
293312
#if defined(WIN32) || defined(WIN64)
294313
HKEY hk;
295314
if (::RegOpenKeyEx(HKEY_LOCAL_MACHINE, "Software\\SIL\\FieldWorks", 0,
@@ -304,15 +323,6 @@ bool GetShowAssertMessageBox()
304323
if (ret == ERROR_SUCCESS)
305324
return fShowAssertMessageBox ? true : false; // otherwise we get a performance warning
306325
}
307-
// getenv is deprecated on Windows
308-
#pragma warning(push)
309-
#pragma warning(disable: 4996)
310-
311-
// Windows doesn't know strcasecmp, it calls it stricmp instead...
312-
#ifndef strcasecmp
313-
#define strcasecmp stricmp
314-
#endif
315-
316326
#endif // WIN32
317327
const char* pEnvVar = getenv("AssertUiEnabled");
318328
return !pEnvVar ||
@@ -581,6 +591,11 @@ void __cdecl SilAssert (
581591
else
582592
OutputDebugString(assertbuf);
583593

594+
// Always mirror assertion text to the process error stream so test runners,
595+
// humans, and automation can see the failure details without a debugger.
596+
fprintf(stderr, "%s\n", assertbuf);
597+
fflush(stderr);
598+
584599
// NOTE: this method is intented to be used by unmanaged apps only;
585600
// managed apps should use DebugProcs.AssertProc in DebugProcs.cs
586601

Src/Generic/Test/TestGeneric.vcxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
$(FwRoot)\Include;
8787
$(FwRoot)\Src\Generic;
8888
$(FwRoot)\Src\Generic\Test;
89+
$(FwRoot)\Src\DebugProcs;
8990
$(FwRoot)\Output\$(Configuration);
9091
$(FwRoot)\Output\$(Configuration)\Common;
9192
$(IntDir);

Src/Generic/Test/testGeneric.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ Last reviewed:
1111
-------------------------------------------------------------------------------*//*:End Ignore*/
1212
#include "testGenericLib.h"
1313
#include "RedirectHKCU.h"
14+
#include "DebugProcs.h"
1415

1516
namespace unitpp
1617
{
1718
void GlobalSetup(bool verbose)
1819
{
20+
ShowAssertMessageBox(0); // Disable assertion dialogs
1921
#if defined(WIN32) || defined(WIN64)
2022
ModuleEntry::DllMain(0, DLL_PROCESS_ATTACH);
2123
#endif

0 commit comments

Comments
 (0)