Skip to content

Commit 8e265ba

Browse files
committed
Tests: Armor OpenOrCreateDialog.wait_for() to be more robust against MainWindow being left open
1 parent da7efdb commit 8e265ba

File tree

4 files changed

+91
-7
lines changed

4 files changed

+91
-7
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ and new `--port`/`--host` options for controlling how projects are served.
6969
to refer to when making autonomous changes to Crystal.
7070
* Decrease average cost of CI runs by running fewer macOS jobs by default.
7171

72+
* Testing improvements
73+
* Automated tests will now recover gracefully if a previous failed test
74+
left a MainWindow open.
75+
7276
### v1.10.0 (June 21, 2025)
7377

7478
This release contains significant usability improvements: Reopening a project

src/crystal/tests/index.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
test_icons, test_install_to_desktop, test_load_urls, test_log_drawer,
99
test_menus, test_new_group, test_new_root_url, test_open_project,
1010
test_parse_html, test_profile, test_project_migrate, test_readonly_mode,
11-
test_server, test_shell, test_ssd, test_tasks, test_tasktree,
11+
test_runner, test_server, test_shell, test_ssd, test_tasks, test_tasktree,
1212
test_untitled_projects,
1313
test_workflows, test_xthreading,
1414
)
@@ -68,6 +68,7 @@ def _test_functions_in_module(mod) -> list[Callable]:
6868
_test_functions_in_module(test_profile) +
6969
_test_functions_in_module(test_project_migrate) +
7070
_test_functions_in_module(test_readonly_mode) +
71+
_test_functions_in_module(test_runner) +
7172
_test_functions_in_module(test_server) +
7273
_test_functions_in_module(test_shell) +
7374
_test_functions_in_module(test_ssd) +

src/crystal/tests/test_runner.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Tests for Crystal's test runner functionality.
3+
4+
These tests verify that the test infrastructure itself works correctly,
5+
including error recovery mechanisms, and test utilities.
6+
"""
7+
8+
from contextlib import redirect_stderr
9+
from crystal.tests.util.asserts import assertIn
10+
from crystal.tests.util.windows import OpenOrCreateDialog
11+
import io
12+
13+
14+
# === OpenOrCreateDialog Tests ===
15+
16+
async def test_when_main_window_left_open_then_ocd_wait_for_does_recover_gracefully() -> None:
17+
# Intentionally leave a MainWindow open, simulating a test failure
18+
ocd1 = await OpenOrCreateDialog.wait_for()
19+
main_window = await ocd1.create_and_leave_open()
20+
21+
# Wait for an OpenOrCreateDialog to appear, simulating a newly started test.
22+
# But it won't appear because the MainWindow is still open.
23+
# OpenOrCreateDialog should detect this and attempt to recover automatically.
24+
#
25+
# The recovery mechanism should:
26+
# 1. Detect that a MainWindow is open
27+
# 2. Print a warning message
28+
# 3. Close the MainWindow (handling any "Do you want to save?" dialog)
29+
# 4. Wait for the OpenOrCreateDialog to appear
30+
with redirect_stderr(io.StringIO()) as captured_stderr:
31+
ocd2 = await OpenOrCreateDialog.wait_for(timeout=1.0)
32+
assertIn(
33+
'WARNING: OpenOrCreateDialog.wait_for() noticed that a MainWindow was left open',
34+
captured_stderr.getvalue()
35+
)

src/crystal/tests/util/windows.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
WaitTimedOut, window_condition, window_disposed_condition,
1818
)
1919
import crystal.tests.util.xtempfile as xtempfile
20+
from crystal.util.wx_dialog import mocked_show_modal
2021
from crystal.util.xos import is_mac_os
2122
import os.path
2223
import re
2324
import sys
2425
import tempfile
2526
import traceback
2627
from typing import TYPE_CHECKING
28+
from unittest.mock import patch
2729
import wx
2830

2931
if TYPE_CHECKING:
@@ -43,13 +45,55 @@ class OpenOrCreateDialog:
4345
create_button: wx.Button
4446

4547
@staticmethod
46-
async def wait_for(timeout: float | None=None) -> OpenOrCreateDialog:
48+
async def wait_for(timeout: float | None=None, *, _attempt_recovery: bool=True) -> OpenOrCreateDialog:
4749
self = OpenOrCreateDialog(ready=True)
48-
open_or_create_project_dialog = await wait_for(
49-
window_condition('cr-open-or-create-project'),
50-
timeout=timeout,
51-
stacklevel_extra=1,
52-
) # type: wx.Window
50+
try:
51+
open_or_create_project_dialog = await wait_for(
52+
window_condition('cr-open-or-create-project'),
53+
timeout=timeout,
54+
stacklevel_extra=1,
55+
) # type: wx.Window
56+
except WaitTimedOut as e:
57+
if _attempt_recovery:
58+
# Check if a MainWindow is open from a previous failed test
59+
try:
60+
main_window: wx.Window = await wait_for(
61+
window_condition('cr-main-window'),
62+
# Short timeout. Check whether a MainWindow is open.
63+
timeout=0.1,
64+
# Attribute failure to this recovery logic
65+
stacklevel_extra=0,
66+
)
67+
assert isinstance(main_window, wx.Frame)
68+
69+
print(
70+
'WARNING: OpenOrCreateDialog.wait_for() noticed that '
71+
'a MainWindow was left open. '
72+
'Did a previous test fail to close it?',
73+
file=sys.stderr
74+
)
75+
76+
# 1. Try to close MainWindow that is open
77+
# 2. If prompted whether to save the project, answer no
78+
with patch('crystal.browser.ShowModal',
79+
mocked_show_modal('cr-save-changes-dialog', wx.ID_NO)):
80+
main_window.Close()
81+
82+
await wait_for(
83+
window_disposed_condition('cr-main-window'),
84+
timeout=4.0, # 2.0s isn't long enough for macOS test runners on GitHub Actions
85+
# Attribute failure to this recovery logic
86+
stacklevel_extra=0,
87+
)
88+
89+
# Try again to wait for the OpenOrCreateDialog
90+
return await OpenOrCreateDialog.wait_for(timeout=timeout, _attempt_recovery=False)
91+
except WaitTimedOut:
92+
# Recovery failed
93+
pass
94+
# Raise original timeout
95+
raise e from None
96+
5397
assert isinstance(open_or_create_project_dialog, wx.Dialog)
5498
self.open_or_create_project_dialog = open_or_create_project_dialog
5599
self.open_as_readonly = self.open_or_create_project_dialog.FindWindow(name=

0 commit comments

Comments
 (0)