Skip to content

Commit 89036a8

Browse files
gh-69134: Harden tkinter GUI tests that depend on a mapped widget (GH-152499)
Add wait_until_mapped() and AbstractTkTest.require_mapped() to test_tkinter.support and use them to guard the assertions that need a widget to be actually mapped (winfo_width(), identify(), coords(), ...). This avoids intermittent failures under window managers that do not map the widget promptly, without skipping the unrelated checks. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> (cherry picked from commit 0fff6bd)
1 parent e0f8186 commit 89036a8

4 files changed

Lines changed: 85 additions & 27 deletions

File tree

Lib/test/test_tkinter/support.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import functools
2+
import time
23
import tkinter
34
import unittest
45
from test import support
@@ -45,6 +46,20 @@ def tearDown(self):
4546
w.destroy()
4647
self.root.withdraw()
4748

49+
def require_mapped(self, widget, timeout=None):
50+
"""Realize *widget*, or skip the test if the window manager will
51+
not map it (e.g. a tiling WM or a headless/contended display).
52+
53+
Use this instead of a bare update() before querying realized
54+
geometry (winfo_width(), identify(), coords(), place_info(), ...).
55+
See gh-69134, gh-74941 and bpo-40722.
56+
"""
57+
if timeout is None:
58+
timeout = support.LOOPBACK_TIMEOUT
59+
if not wait_until_mapped(widget, timeout):
60+
self.skipTest('widget was not mapped by the window manager '
61+
f'(timed out after {timeout:g}s)')
62+
4863

4964
class AbstractDefaultRootTest:
5065

@@ -78,6 +93,32 @@ def destroy_default_root():
7893
tkinter._default_root.destroy()
7994
tkinter._default_root = None
8095

96+
def wait_until_mapped(widget, timeout=None):
97+
"""Wait until *widget* is actually mapped and laid out by the window
98+
manager, so that realized-geometry queries (winfo_width(), identify(),
99+
coords(), ...) return meaningful values.
100+
101+
Return True once the widget is mapped with a non-trivial size, or False
102+
if that has not happened within *timeout* seconds (default:
103+
``support.LOOPBACK_TIMEOUT``). Unlike Misc.wait_visibility(), this
104+
never blocks indefinitely, so it is safe under a window manager that
105+
never maps the window (see gh-69134, gh-74941, bpo-40722).
106+
"""
107+
if timeout is None:
108+
timeout = support.LOOPBACK_TIMEOUT
109+
deadline = time.monotonic() + timeout
110+
widget.update_idletasks()
111+
while True:
112+
widget.update() # drain pending Map/Configure events
113+
if (widget.winfo_ismapped()
114+
and widget.winfo_width() > 1
115+
and widget.winfo_height() > 1):
116+
return True
117+
if time.monotonic() >= deadline:
118+
return False
119+
time.sleep(0.01)
120+
121+
81122
def simulate_mouse_click(widget, x, y):
82123
"""Generate proper events to click at the x, y position (tries to act
83124
like an X server)."""

Lib/test/test_tkinter/test_widgets.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from test.test_tkinter.support import setUpModule # noqa: F401
88
from test.test_tkinter.support import (requires_tk, tk_version,
99
get_tk_patchlevel, widget_eq,
10+
wait_until_mapped,
1011
AbstractDefaultRootTest)
1112
from test.test_tkinter.widget_tests import (
1213
add_standard_options,
@@ -689,10 +690,11 @@ def test_invoke(self):
689690
def test_identify(self):
690691
widget = self.create()
691692
widget.pack()
692-
widget.update_idletasks()
693-
# The empty string is returned for a point over no element.
694-
self.assertIn(widget.identify(5, 5),
695-
('entry', 'buttonup', 'buttondown', 'none', ''))
693+
# Identifying the element under a point requires the widget to be
694+
# mapped with a real size.
695+
if wait_until_mapped(widget):
696+
self.assertIn(widget.identify(5, 5),
697+
('entry', 'buttonup', 'buttondown', 'none'))
696698
self.assertRaises(TclError, widget.identify, 'a', 'b')
697699

698700
def test_scan(self):
@@ -2002,9 +2004,11 @@ def test_delta(self):
20022004
def test_identify(self):
20032005
sb = self.create()
20042006
sb.pack(fill='y', expand=True)
2005-
sb.update_idletasks()
2006-
self.assertIn(sb.identify(5, 5),
2007-
('arrow1', 'arrow2', 'slider', 'trough1', 'trough2', ''))
2007+
# Identifying the element under a point requires the widget to be
2008+
# mapped with a real size.
2009+
if wait_until_mapped(sb):
2010+
self.assertIn(sb.identify(5, 5),
2011+
('arrow1', 'arrow2', 'slider', 'trough1', 'trough2'))
20082012
self.assertRaises(TclError, sb.identify, 'a', 'b')
20092013

20102014

@@ -2134,10 +2138,12 @@ def test_identify(self):
21342138
p, b, c = self.create2()
21352139
p.configure(width=200, height=50)
21362140
p.pack()
2137-
p.update()
2138-
x, y = p.sash_coord(0)
2139-
# A point over the sash reports the sash.
2140-
self.assertIn('sash', p.identify(x + 1, y + 5))
2141+
# Locating the sash requires the widget to be mapped with a real
2142+
# size; the rest of the checks do not.
2143+
if wait_until_mapped(p):
2144+
x, y = p.sash_coord(0)
2145+
# A point over the sash reports the sash.
2146+
self.assertIn('sash', p.identify(x + 1, y + 5))
21412147
# A point over a pane reports nothing.
21422148
self.assertFalse(p.identify(2, 2))
21432149
self.assertRaises(TclError, p.identify, 'a', 'b')

Lib/test/test_ttk/test_extensions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def check_positions(scale, scale_pos, label, label_pos):
109109
def test_horizontal_range(self):
110110
lscale = ttk.LabeledScale(self.root, from_=0, to=10)
111111
lscale.pack()
112-
lscale.update()
112+
self.require_mapped(lscale)
113113

114114
linfo_1 = lscale.label.place_info()
115115
prev_xcoord = lscale.scale.coords()[0]
@@ -138,7 +138,7 @@ def test_horizontal_range(self):
138138
def test_variable_change(self):
139139
x = ttk.LabeledScale(self.root)
140140
x.pack()
141-
x.update()
141+
self.require_mapped(x)
142142

143143
curr_xcoord = x.scale.coords()[0]
144144
newval = x.value + 1
@@ -181,7 +181,7 @@ def test_resize(self):
181181
x = ttk.LabeledScale(self.root)
182182
x.pack(expand=True, fill='both')
183183
gc_collect() # For PyPy or other GCs.
184-
x.update()
184+
self.require_mapped(x)
185185

186186
width, height = x.master.winfo_width(), x.master.winfo_height()
187187
width_new, height_new = width * 2, height * 2

Lib/test/test_ttk/test_widgets.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from test.test_tkinter.support import setUpModule # noqa: F401
99
from test.test_tkinter.support import (
1010
AbstractTkTest, requires_tk, tk_version, get_tk_patchlevel,
11-
simulate_mouse_click, AbstractDefaultRootTest)
11+
simulate_mouse_click, wait_until_mapped, AbstractDefaultRootTest)
1212
from test.test_tkinter.widget_tests import (add_standard_options,
1313
AbstractWidgetTest, StandardOptionsTests, IntegerSizeTests, PixelSizeTests)
1414

@@ -78,11 +78,13 @@ def setUp(self):
7878
self.widget.pack()
7979

8080
def test_identify(self):
81-
self.widget.update()
82-
self.assertEqual(self.widget.identify(
83-
int(self.widget.winfo_width() / 2),
84-
int(self.widget.winfo_height() / 2)
85-
), "label")
81+
# Identifying the element under a point requires the widget to be
82+
# mapped with a real size; the rest of the checks do not.
83+
if wait_until_mapped(self.widget):
84+
self.assertEqual(self.widget.identify(
85+
int(self.widget.winfo_width() / 2),
86+
int(self.widget.winfo_height() / 2)
87+
), "label")
8688
self.assertEqual(self.widget.identify(-1, -1), "")
8789

8890
self.assertRaises(tkinter.TclError, self.widget.identify, None, 5)
@@ -373,9 +375,11 @@ def test_bbox(self):
373375

374376
def test_identify(self):
375377
self.entry.pack()
376-
self.entry.update()
377378

378-
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
379+
# Identifying the element under a point requires the widget to be
380+
# mapped with a real size; the rest of the checks do not.
381+
if wait_until_mapped(self.entry):
382+
self.assertIn(self.entry.identify(5, 5), self.IDENTIFY_AS)
379383
self.assertEqual(self.entry.identify(-1, -1), "")
380384

381385
self.assertRaises(tkinter.TclError, self.entry.identify, None, 5)
@@ -491,7 +495,7 @@ def test_virtual_event(self):
491495
self.combo.bind('<<ComboboxSelected>>',
492496
lambda evt: success.append(True))
493497
self.combo.pack()
494-
self.combo.update()
498+
self.require_mapped(self.combo)
495499

496500
height = self.combo.winfo_height()
497501
self._show_drop_down_listbox()
@@ -506,7 +510,7 @@ def test_configure_postcommand(self):
506510

507511
self.combo['postcommand'] = lambda: success.append(True)
508512
self.combo.pack()
509-
self.combo.update()
513+
self.require_mapped(self.combo)
510514

511515
self._show_drop_down_listbox()
512516
self.assertTrue(success)
@@ -853,8 +857,10 @@ def test_get(self):
853857
else:
854858
conv = float
855859

856-
scale_width = self.scale.winfo_width()
857-
self.assertEqual(self.scale.get(scale_width, 0), self.scale['to'])
860+
# Reading the value at the far edge needs the realized width.
861+
if wait_until_mapped(self.scale):
862+
scale_width = self.scale.winfo_width()
863+
self.assertEqual(self.scale.get(scale_width, 0), self.scale['to'])
858864

859865
self.assertEqual(conv(self.scale.get(0, 0)), conv(self.scale['from']))
860866
self.assertEqual(self.scale.get(), self.scale['value'])
@@ -896,7 +902,10 @@ def test_set(self):
896902
# nevertheless, note that the max/min values we can get specifying
897903
# x, y coords are the ones according to the current range
898904
self.assertEqual(conv(self.scale.get(0, 0)), min)
899-
self.assertEqual(conv(self.scale.get(self.scale.winfo_width(), 0)), max)
905+
# Reading the value at the far edge needs the realized width.
906+
if wait_until_mapped(self.scale):
907+
self.assertEqual(
908+
conv(self.scale.get(self.scale.winfo_width(), 0)), max)
900909

901910
self.assertRaises(tkinter.TclError, self.scale.set, None)
902911

@@ -1238,6 +1247,7 @@ def create(self, **kwargs):
12381247
return ttk.Spinbox(self.root, **kwargs)
12391248

12401249
def _click_increment_arrow(self):
1250+
self.require_mapped(self.spin)
12411251
width = self.spin.winfo_width()
12421252
height = self.spin.winfo_height()
12431253
x = width - 5
@@ -1248,6 +1258,7 @@ def _click_increment_arrow(self):
12481258
self.spin.update_idletasks()
12491259

12501260
def _click_decrement_arrow(self):
1261+
self.require_mapped(self.spin)
12511262
width = self.spin.winfo_width()
12521263
height = self.spin.winfo_height()
12531264
x = width - 5

0 commit comments

Comments
 (0)