Repository navigation
Expand file tree
/
Copy pathgershwin-windowmanager-qa-plan.html
More file actions
715 lines (640 loc) · 37.6 KB
/
Copy pathgershwin-windowmanager-qa-plan.html
File metadata and controls
715 lines (640 loc) · 37.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gershwin WindowManager — QA Plan</title>
<style>
:root {
--fg: #1d2733;
--muted: #5d6b7a;
--bg: #ffffff;
--panel: #f6f8fb;
--panel-2: #eef2f7;
--accent: #2d6cdf;
--accent-soft: #e6efff;
--border: #d7dde5;
--good: #1e8452;
--warn: #b86f00;
--bad: #b13030;
--code-bg: #0f172a;
--code-fg: #e2e8f0;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--fg);
background: var(--bg);
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 32px 28px 80px; }
header.title {
border-bottom: 1px solid var(--border);
padding-bottom: 18px;
margin-bottom: 28px;
}
header.title h1 { margin: 0 0 6px; font-size: 28px; letter-spacing: -0.01em; }
header.title .sub { color: var(--muted); font-size: 14px; }
header.title .meta { margin-top: 8px; font-size: 13px; color: var(--muted); }
h2 {
margin: 36px 0 12px;
font-size: 22px;
letter-spacing: -0.01em;
padding-bottom: 6px;
border-bottom: 2px solid var(--accent-soft);
}
h3 { margin: 24px 0 8px; font-size: 17px; }
h4 { margin: 18px 0 6px; font-size: 15px; color: var(--fg); }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 3px 0; }
code {
background: var(--panel-2);
padding: 1px 6px;
border-radius: 3px;
font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre {
background: var(--code-bg);
color: var(--code-fg);
padding: 14px 16px;
border-radius: 6px;
overflow-x: auto;
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre code { background: transparent; padding: 0; color: inherit; }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 18px;
margin: 12px 0 16px;
}
.panel.accent { background: var(--accent-soft); border-color: #b9d0f4; }
.panel.warn { background: #fff7e6; border-color: #f0d28a; }
.toc {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 20px;
}
.toc h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.toc ol { margin: 0; padding-left: 22px; }
.toc a { color: var(--accent); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
table { border-collapse: collapse; width: 100%; margin: 10px 0 18px; font-size: 14px; }
th, td { border: 1px solid var(--border); padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: var(--panel-2); font-weight: 600; }
tbody tr:nth-child(even) { background: #fbfcfe; }
.checklist { list-style: none; padding-left: 0; }
.checklist li {
position: relative;
padding: 6px 8px 6px 32px;
border-bottom: 1px dashed #e3e7ec;
}
.checklist li:last-child { border-bottom: 0; }
.checklist li::before {
content: "";
position: absolute;
left: 6px; top: 9px;
width: 16px; height: 16px;
border: 1.5px solid #9aa6b3;
border-radius: 3px;
background: #fff;
}
.checklist .why { color: var(--muted); font-size: 13px; display: block; margin-top: 2px; }
.pill {
display: inline-block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 999px;
vertical-align: middle;
margin-left: 6px;
}
.pill.smoke { background: #e3f6ec; color: var(--good); }
.pill.regr { background: #fdecec; color: var(--bad); }
.pill.feat { background: #e6efff; color: var(--accent); }
.pill.compos { background: #f3e8ff; color: #6b21a8; }
.pill.optional{ background: #f0f0f0; color: #555; }
.grid { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
.legend { font-size: 13px; color: var(--muted); margin: 6px 0 14px; }
.legend .pill { margin-left: 0; margin-right: 6px; }
.file { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; color: var(--muted); }
.callout-row {
display: grid;
grid-template-columns: 80px 1fr;
gap: 12px;
align-items: start;
padding: 10px 0;
border-bottom: 1px solid #eef2f7;
}
.callout-row:last-child { border-bottom: 0; }
.callout-row .tag {
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted);
padding-top: 2px;
}
.footer-note {
margin-top: 40px;
padding-top: 16px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 13px;
}
</style>
</head>
<body>
<div class="wrap">
<header class="title">
<h1>Gershwin WindowManager — QA Plan</h1>
<div class="sub">A two-track plan: a per-PR manual smoke checklist, and a phased automated test suite.</div>
<div class="meta">
Repository: <code>gershwin-desktop/gershwin-windowmanager</code> ·
Drafted: 2026-05-02 ·
Audience: maintainers & contributors
</div>
</header>
<div class="toc">
<h3>Contents</h3>
<ol>
<li><a href="#context">Context & goals</a></li>
<li><a href="#manual">Manual testing checklist for every PR</a></li>
<li><a href="#baseline">Known-state baseline (do this once)</a></li>
<li><a href="#automated">Automated testing — three tiers</a></li>
<li><a href="#ci">CI workflow</a></li>
<li><a href="#rollout">Phased rollout (4 milestones)</a></li>
<li><a href="#mapping">Feature → test coverage map</a></li>
</ol>
</div>
<!-- =========================== CONTEXT =========================== -->
<h2 id="context">1. Context & goals</h2>
<p>
WindowManager is a single-binary X11 reparenting WM written in Objective-C against
GNUstep + libxcb, with an optional XRender compositor. The codebase has grown
faster than its safety net: today there are no automated tests, no CI, and only
two interactive Xephyr launch scripts (<code>WindowManager/test-with-xephyr.sh</code>,
<code>WindowManager/test-xephyr-compositor.sh</code>) that a human watches by eye.
Recent regressions (workspace windows wandering after open/close, Chromium
scroll-wheel redraws, the paste-to-LoginWindow incident) all slipped through
green PRs because there was nothing to catch them.
</p>
<div class="panel accent">
<strong>What this plan optimises for</strong>
<ul>
<li><strong>No bottleneck on the maintainers.</strong> The manual checklist must be cheap enough that any contributor can run it on their own branch.</li>
<li><strong>Catch the regressions we’ve already seen at least once.</strong> Each known incident becomes a checklist item now and a regression test later.</li>
<li><strong>Move the cost from humans to machines over time.</strong> Every checklist item should have a clear path to becoming an automated test, so the manual list shrinks instead of growing forever.</li>
</ul>
</div>
<p><strong>Two tracks, intentionally:</strong></p>
<ul>
<li><strong>Track A — Manual checklist.</strong> A markdown checklist auto-rendered into every PR by a GitHub Action. Authors tick boxes; reviewers can see what was actually exercised. Ships in week 1.</li>
<li><strong>Track B — Automated suite.</strong> Three tiers (unit / X11 integration / UI driver) running headless under <code>Xvfb</code>. Built incrementally; every checklist item is a candidate to be retired into automation.</li>
</ul>
<!-- =========================== MANUAL =========================== -->
<h2 id="manual">2. Manual testing checklist for every PR</h2>
<p>
Drop this as <code>.github/PULL_REQUEST_TEMPLATE.md</code> so it appears on every new PR.
The author ticks what they exercised; unticked items are not "fail" — they are
"untested in this PR", which is itself useful information for the reviewer.
</p>
<p class="legend">
<span class="pill smoke">smoke</span> always run, < 2 min total ·
<span class="pill regr">regression</span> covers a real past bug ·
<span class="pill feat">feature</span> only if you touched this area ·
<span class="pill compos">compositor</span> run twice: with and without <code>-dc</code> ·
<span class="pill optional">optional</span> deeper sweep
</p>
<h3>2.1 Smoke (always run)</h3>
<ul class="checklist">
<li>WM starts cleanly under Xephyr from <code>./test-with-xephyr.sh</code> with no crash and no errors on stderr <span class="pill smoke">smoke</span>
<span class="why">Catches startup regressions in selection ownership, theme load, signal handlers.</span></li>
<li>WM starts cleanly under Xephyr from <code>./test-xephyr-compositor.sh</code>; compositor initialises (no fallback message) <span class="pill smoke">smoke</span><span class="pill compos">compositor</span>
<span class="why">Catches XRender/COMPOSITE/DAMAGE/XFIXES init regressions.</span></li>
<li>Open <code>xterm</code> — it gets a titlebar, the three orb buttons, and 1px border (or 0px in compositor mode) <span class="pill smoke">smoke</span></li>
<li>Open a second window — titlebar of the unfocused window dims (~35% gray overlay) <span class="pill smoke">smoke</span></li>
<li>Click on the unfocused window — focus moves, dimming inverts, <code>_NET_ACTIVE_WINDOW</code> updates (verify with <code>xprop -root _NET_ACTIVE_WINDOW</code>) <span class="pill smoke">smoke</span></li>
<li>Drag a window by the titlebar — it follows the cursor with no tearing or stutter <span class="pill smoke">smoke</span></li>
<li>Resize from each of the four corners — geometry updates correctly, no visual glitch on the corner radius <span class="pill smoke">smoke</span></li>
<li>Click red close orb — window closes (sends <code>WM_DELETE_WINDOW</code> if supported, else destroys) <span class="pill smoke">smoke</span></li>
<li>Click yellow minimize orb — window unmaps; <code>_NET_WM_STATE</code> includes <code>_NET_WM_STATE_HIDDEN</code> <span class="pill smoke">smoke</span></li>
<li>Click green zoom orb — window maximises into the workarea (not over the dock); click again restores <span class="pill smoke">smoke</span></li>
<li>Alt+Tab cycles through windows; Shift+Alt+Tab cycles in reverse; releasing Alt commits focus <span class="pill smoke">smoke</span></li>
<li>Quit the WM (Ctrl+C in the launching shell) — clean exit, no zombie processes, decorations get released <span class="pill smoke">smoke</span></li>
</ul>
<h3>2.2 Regression checks (each ties to a real past bug)</h3>
<ul class="checklist">
<li>Open and close a Workspace window 5 times in a row at the same spot — subsequent opens stay at the same position (no "wandering") <span class="pill regr">regression</span>
<span class="why">Reported by @probonopd after PR #64. Frame-origin / EWMH frame-extents accounting bug class.</span></li>
<li>In Chromium, scroll wheel inside a page repaints content immediately — no stale tile, no half-painted scroll <span class="pill regr">regression</span><span class="pill compos">compositor</span>
<span class="why">Reported as a redraw failure after the EWMH PR. Damage-tracking regression surface.</span></li>
<li>Run an app that prints to stderr after the WM has closed its log fd — WM does not crash on SIGPIPE <span class="pill regr">regression</span>
<span class="why">SIGPIPE handling in <code>main.m</code>.</span></li>
<li>Toggle CapsLock on, then Alt+Tab — switcher still works (NumLock/CapsLock modifier-mask insensitivity) <span class="pill regr">regression</span></li>
<li>Right-click on a titlebar to open the snap menu, then drag the cursor off the menu and release — no lockup <span class="pill regr">regression</span></li>
<li>While dragging a window, kill the client process (e.g. <code>kill -9</code>) — WM does not stay stuck in drag state for the next window <span class="pill regr">regression</span></li>
<li>Start the WM with no <code>~/GNUstep/Defaults/uroswm.plist</code> — runs cleanly, doesn't assume compositor preference <span class="pill regr">regression</span></li>
<li>With the X server under load (or briefly suspended), the WM does not crash on a NULL xcb reply <span class="pill regr">regression</span>
<span class="why">Recent fixes in <code>EWMHService.m</code> and the focus-rebuild path.</span></li>
<li><code>xprop</code> on a Chromium window shows the full EWMH set: <code>_NET_WM_PID</code>, <code>_NET_WM_WINDOW_TYPE</code>, <code>_NET_WM_NAME</code>, <code>_NET_WM_ALLOWED_ACTIONS</code>, <code>_NET_WM_DESKTOP</code>, <code>_NET_FRAME_EXTENTS</code> <span class="pill regr">regression</span>
<span class="why">PR #64's reason for existing — if any of these go missing, we've regressed.</span></li>
<li>Open Claude / a terminal app, copy text from elsewhere, paste into it — no logoff, no LoginWindow drop <span class="pill regr">regression</span>
<span class="why">The selection / focus interaction that bit @pkgdemon last week.</span></li>
</ul>
<h3>2.3 Feature areas (run only those you touched)</h3>
<h4>Window decoration / titlebar</h4>
<ul class="checklist">
<li>Titlebar height matches the active GSTheme; switching theme updates it <span class="pill feat">feature</span></li>
<li>Window title is centred, antialiased, with shadow; long titles truncate without overflow <span class="pill feat">feature</span></li>
<li>Titlebar gradient renders top-to-bottom (light gray → dark gray); rounded corners are clean against the wallpaper <span class="pill feat">feature</span></li>
<li>Resize handle (grow box) is positioned per theme metrics and triggers diagonal resize <span class="pill feat">feature</span></li>
</ul>
<h4>Resize & move</h4>
<ul class="checklist">
<li>Resize from each edge (N, S, E, W) and corner (NW, NE, SW, SE) — cursor changes appropriately <span class="pill feat">feature</span></li>
<li>Window honours <code>WM_NORMAL_HINTS</code> min/max size (test with <code>xterm -geometry 20x5</code>) <span class="pill feat">feature</span></li>
<li>WM-defined minimum (496×431) is enforced; clients smaller than that get clamped <span class="pill feat">feature</span></li>
<li>Motion is compressed during drag — CPU does not spike, no event-queue flooding <span class="pill feat">feature</span></li>
</ul>
<h4>Snapping</h4>
<ul class="checklist">
<li>Drag to top edge: maximise preview after 300ms linger; release commits maximise <span class="pill feat">feature</span></li>
<li>Drag to left/right edge: half-screen snap preview; release tiles <span class="pill feat">feature</span></li>
<li>Drag to each of the four corners: quarter-screen snap preview <span class="pill feat">feature</span></li>
<li>Right-click titlebar → snap menu shows: Center, Maximize Vert, Maximize Horiz, snap-to-* <span class="pill feat">feature</span></li>
<li>Snap zones respect dock struts (window does not slide under the dock) <span class="pill feat">feature</span></li>
</ul>
<h4>Focus management</h4>
<ul class="checklist">
<li>Closing the focused window of an app with another window open: focus stays in the same app (same-PID preference) <span class="pill feat">feature</span></li>
<li>Closing the only focused window: focus falls back to the previously-focused window if still mapped <span class="pill feat">feature</span></li>
<li>With no other regular windows: focus falls back to the Desktop window without crashing <span class="pill feat">feature</span></li>
<li>X11 focus changes are mirrored into AppKit activation (test with a GNUstep app: menubar should reflect focus) <span class="pill feat">feature</span></li>
<li>Override-redirect popups (menus, tooltips) do <strong>not</strong> get decorated and do <strong>not</strong> steal focus <span class="pill feat">feature</span></li>
<li>Modal dialogs (<code>_NET_WM_STATE_MODAL</code>) stack above their parent and trap focus <span class="pill feat">feature</span></li>
</ul>
<h4>EWMH / ICCCM</h4>
<ul class="checklist">
<li><code>wmctrl -l</code> lists all managed windows in mapped order <span class="pill feat">feature</span></li>
<li><code>wmctrl -d</code> shows one desktop with the workarea correctly reduced by any dock <span class="pill feat">feature</span></li>
<li><code>xprop -root _NET_SUPPORTED</code> includes the atoms the WM advertises — spot-check no recent atoms have disappeared <span class="pill feat">feature</span></li>
<li>Sending <code>_NET_CLOSE_WINDOW</code> via <code>wmctrl -c</code> closes the window gracefully <span class="pill feat">feature</span></li>
<li>Sending <code>_NET_ACTIVE_WINDOW</code> via <code>wmctrl -a</code> raises and focuses <span class="pill feat">feature</span></li>
<li>A dock window with <code>_NET_WM_STRUT_PARTIAL</code> reduces <code>_NET_WORKAREA</code>; maximised windows respect it <span class="pill feat">feature</span></li>
</ul>
<h4>Compositor</h4>
<ul class="checklist">
<li>Run with and without <code>-dc</code> — both modes start and decorate windows <span class="pill compos">compositor</span></li>
<li>Open animation (Workspace sets <code>_GERSHWIN_WINDOW_OPEN_ANIMATION_RECT</code>) plays from the source rect to the final window position <span class="pill compos">compositor</span></li>
<li>Minimize animation shrinks toward the dock area; restore animation expands back <span class="pill compos">compositor</span></li>
<li>Drag a window — no tearing on the leading edge; titlebar stays crisp <span class="pill compos">compositor</span></li>
<li>Quickly map and unmap the same window 10 times — no orphan shadow/pixmap leak (check VSZ doesn't grow unboundedly) <span class="pill compos">compositor</span></li>
</ul>
<h4>Multi-monitor (optional)</h4>
<ul class="checklist">
<li>If running with two screens (Xephyr <code>+xinerama -screen 1024x768 -screen 1024x768</code>): per-screen workarea correct; windows can be dragged across the boundary <span class="pill optional">optional</span></li>
</ul>
<div class="panel warn">
<strong>Reviewer expectation:</strong> the smoke and any regression items overlapping the diff must be ticked
before merge. Feature-area items only need ticking when the PR touches that area.
A reviewer should never have to re-run smoke themselves — if it isn't ticked, request changes.
</div>
<!-- =========================== BASELINE =========================== -->
<h2 id="baseline">3. Known-state baseline (do this once)</h2>
<p>
Before the checklist is meaningful, we need a public document of <em>what currently
works and what doesn’t</em>. Otherwise reviewers will mark items "fail" for
pre-existing issues and waste cycles. Create a single living page in the wiki
(or a <code>docs/QA-BASELINE.md</code>) with this shape:
</p>
<table>
<thead>
<tr><th>Area</th><th>Status</th><th>Last verified commit</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td>Titlebar appearance under Eau theme</td><td>✓ works</td><td><code>a419bf0</code></td><td>—</td></tr>
<tr><td>EWMH atoms on Chromium window</td><td>✓ works</td><td><code>a419bf0</code></td><td>Fixed by PR #64.</td></tr>
<tr><td>Compositor — tearing during drag</td><td>⚠ known issue</td><td><code>a419bf0</code></td><td>Visible on fast pointer movement; tracked separately.</td></tr>
<tr><td>Workspace window position stability</td><td>⚠ verify</td><td>—</td><td>Concern raised after PR #64; needs explicit pass before claiming green.</td></tr>
<tr><td>Chromium scroll-wheel repaint</td><td>⚠ verify</td><td>—</td><td>Same source PR; needs explicit pass.</td></tr>
<tr><td>Multi-monitor workarea</td><td>❓ untested</td><td>—</td><td>No CI for it yet.</td></tr>
</tbody>
</table>
<p>
Update one row per merged PR that touches the area. The table is the contract:
if it says "works" at commit X, a reviewer can hold a future PR to that bar.
</p>
<!-- =========================== AUTOMATED =========================== -->
<h2 id="automated">4. Automated testing — three tiers</h2>
<p>
A window manager is an inherently graphical, event-driven program with deep
state. No single test framework covers it well, so use a layered approach.
Each tier costs more to write and run than the one above, so we add tiers in
order of payoff.
</p>
<table>
<thead><tr><th>Tier</th><th>Tests</th><th>Tools</th><th>Runs in</th><th>Catches</th></tr></thead>
<tbody>
<tr>
<td><strong>1. Unit</strong></td>
<td>Pure-ObjC components: atom interning, geometry transforms, MWM/EWMH parsers, focus stack logic, snap-zone math, comparator/transformer utilities</td>
<td><code>gnustep-tests</code> + <code>ObjectTesting.h</code> (same as <code>libs-base/Tests/</code>)</td>
<td>No X server — <code>make check</code></td>
<td>Logic regressions, NULL-deref guards, off-by-one in geometry math</td>
</tr>
<tr>
<td><strong>2. X11 integration</strong></td>
<td>Protocol-level: window gets framed, atoms appear on root and clients, <code>_NET_ACTIVE_WINDOW</code> follows focus, <code>_NET_WORKAREA</code> shrinks under struts, <code>WM_DELETE_WINDOW</code> closes, <code>_NET_CLOSE_WINDOW</code> client message works</td>
<td>Bash harness + <code>Xvfb</code> + <code>xprop</code> + <code>wmctrl</code> + <code>xwininfo</code></td>
<td>Headless — <code>make check-integration</code> and CI</td>
<td>EWMH compliance regressions, atom drops, frame-extents math, root-property updates</td>
</tr>
<tr>
<td><strong>3. UI driver</strong></td>
<td>End-to-end: open windows, drag/resize/close them, exercise Alt-Tab, snap to edges, screenshot diff against a golden image</td>
<td>Python + <code>xdotool</code> + <code>wmctrl</code> + <code>scrot</code> (model: <code>gershwin-workspace/Tools/uitest</code>)</td>
<td>Headless — <code>make check-ui</code> and CI nightly</td>
<td>Focus/drag/snap regressions, animation breakage, "wandering window" class of bug</td>
</tr>
</tbody>
</table>
<h3>4.1 Tier 1 — Unit tests with <code>gnustep-tests</code></h3>
<p>
Mirror the <code>libs-base/Tests/</code> layout. Add a <code>Tests/</code> tree at the repo root:
</p>
<pre><code>gershwin-windowmanager/
├── WindowManager/ # production code (existing)
├── Tests/
│ ├── GNUmakefile # check:: target, runs gnustep-tests
│ ├── unit/
│ │ ├── XCBAtomService_basic.m
│ │ ├── FocusStack_samePidPreference.m
│ │ ├── SnapZone_geometry.m
│ │ ├── EWMHParser_strut.m
│ │ ├── Transformers_coords.m
│ └── integration/ # tier 2 (see 4.2)
└── GNUmakefile # add Tests to SUBPROJECTS
</code></pre>
<p>Example unit test (mirrors the style libs-base uses):</p>
<pre><code>// Tests/unit/FocusStack_samePidPreference.m
#import <Foundation/Foundation.h>
#import "ObjectTesting.h"
#import "URSFocusManager.h"
int main(void) {
NSAutoreleasePool *p = [NSAutoreleasePool new];
URSFocusManager *fm = [URSFocusManager new];
// Three windows, two share a PID.
[fm trackWindow:0x100 pid:42];
[fm trackWindow:0x101 pid:42];
[fm trackWindow:0x102 pid:99];
[fm setFocusedWindow:0x100];
// When 0x100 closes, focus should prefer 0x101 (same PID), not 0x102.
XCBWindowID next = [fm nextFocusCandidateAfterRemoving:0x100];
PASS(next == 0x101, "same-pid window preferred over other-pid window");
[p release];
return 0;
}
</code></pre>
<p>
What to unit-test first (highest bug-yield, no X server needed):
</p>
<ul>
<li><strong>Focus reassignment policy</strong> — the same-PID → previous → desktop → any precedence chain in <code>URSFocusManager</code>.</li>
<li><strong>Snap-zone geometry</strong> — given a workarea + cursor position, the right zone is selected for every edge and corner threshold.</li>
<li><strong>Frame-extents math</strong> — given a client geometry + titlebar height + border, frame extents and the synthetic <code>ConfigureNotify</code> position are correct.</li>
<li><strong>EWMH/ICCCM parsers</strong> — <code>_NET_WM_STRUT_PARTIAL</code> (12 cardinals), <code>_MOTIF_WM_HINTS</code>, <code>WM_NORMAL_HINTS</code> aspect ratio.</li>
<li><strong>Atom service</strong> — intern/lookup, GNUstep-specific atoms, no double-intern, no NULL on wedged connection.</li>
<li><strong>NULL-reply guards</strong> — reproduce the <code>EWMHService getProperty</code> wedged-connection case (the recent crash) with a stub connection.</li>
</ul>
<p>Add to <code>Tests/GNUmakefile</code>:</p>
<pre><code>include $(GNUSTEP_MAKEFILES)/common.make
check::
	ADDITIONAL_INCLUDE_DIRS="-I$(CURDIR)/../WindowManager -I$(CURDIR)/../WindowManager/xcb \
	 -I$(CURDIR)/../WindowManager/xcb/services -I$(CURDIR)/../WindowManager/xcb/enums \
	 -I$(CURDIR)/../WindowManager/xcb/utils" \
	gnustep-tests --timeout 60 unit
</code></pre>
<h3>4.2 Tier 2 — X11 integration tests under Xvfb</h3>
<p>
These exercise the WM as a black box against a real X server. Each test starts
a clean <code>Xvfb</code> on a private display, launches the WM, performs an action
(map a test window, send a client message, change a property), and asserts on
the resulting X11 state with <code>xprop</code>/<code>wmctrl</code>/<code>xwininfo</code>.
</p>
<pre><code>#!/usr/bin/env bash
# Tests/integration/lib.sh
start_wm() {
Xvfb :99 -screen 0 1024x768x24 &
XVFB_PID=$!
export DISPLAY=:99
sleep 0.3
../../WindowManager/obj/WindowManager $@ >wm.log 2>&1 &
WM_PID=$!
sleep 0.5
}
stop_wm() { kill $WM_PID 2>/dev/null; kill $XVFB_PID 2>/dev/null; }
trap stop_wm EXIT
assert_atom_present() {
xprop -id "$1" "$2" 2>/dev/null | grep -q "$2" \
|| { echo "FAIL: $2 missing on $1"; exit 1; }
}
</code></pre>
<pre><code>#!/usr/bin/env bash
# Tests/integration/01_ewmh_atoms_on_client.sh
# Regression target: PR #64 — client windows must carry the full EWMH set.
source ./lib.sh
start_wm
xterm -display :99 -e 'sleep 30' &
sleep 0.4
WID=$(xdotool search --class xterm | head -1)
for atom in _NET_WM_PID _NET_WM_WINDOW_TYPE _NET_WM_NAME \
_NET_WM_ALLOWED_ACTIONS _NET_WM_DESKTOP _NET_FRAME_EXTENTS; do
assert_atom_present "$WID" "$atom"
done
echo OK
</code></pre>
<p>Integration tests to add first (each maps to a known regression):</p>
<ol>
<li><strong>EWMH atoms appear on client.</strong> The test above. Locks in PR #64.</li>
<li><strong><code>_NET_ACTIVE_WINDOW</code> follows focus.</strong> Map two windows; <code>xdotool windowfocus</code> the second; assert root atom updates.</li>
<li><strong><code>_NET_WORKAREA</code> shrinks under a strut.</strong> Map a window with <code>_NET_WM_STRUT_PARTIAL</code> set; assert workarea reduces by the strut amount.</li>
<li><strong>Window position stability across map/unmap.</strong> Map at (200, 200), unmap, remap — geometry stays at (200, 200). This catches the “wandering windows” class.</li>
<li><strong><code>_NET_CLOSE_WINDOW</code> closes the window.</strong> <code>wmctrl -c</code>, then assert window count drops.</li>
<li><strong>Wedged-connection survival.</strong> Send a malformed property request via raw xcb; assert WM doesn’t crash (process still alive).</li>
<li><strong>WM_S0 takeover.</strong> Start a stub WM, then start ours with takeover; assert ours owns <code>WM_S0</code>.</li>
</ol>
<h3>4.3 Tier 3 — UI driver tests</h3>
<p>
The model is <code>gershwin-workspace/Tools/uitest</code>: a Python suite that drives
synthetic input via <code>xdotool</code>, queries state via <code>wmctrl</code>/<code>xwininfo</code>,
and captures screenshots via <code>scrot</code> on failure. We do <strong>not</strong> need
the NSConnection IPC piece — for a window manager the X server itself is the
oracle.
</p>
<pre><code># Tests/ui/test_drag_preserves_geometry.py
def test_drag_window_lands_at_cursor():
win = open_xterm()
move_window(win, 100, 100)
assert geometry(win) == (100, 100, ...)
drag_titlebar(win, dx=300, dy=200)
x, y, _, _ = geometry(win)
assert abs(x - 400) <= 2 and abs(y - 300) <= 2, \
f"drag landed at ({x}, {y}), expected ~(400, 300)"
</code></pre>
<p>UI scenarios worth automating, in priority order:</p>
<ol>
<li><strong>Drag → release lands at cursor.</strong> Catches motion-compression and frame-offset regressions.</li>
<li><strong>Resize each corner moves the right edges.</strong> Catches the 8-direction matrix.</li>
<li><strong>Snap to top maximises into workarea.</strong> Drag to (screen_w/2, 0), release after linger.</li>
<li><strong>Alt+Tab cycles & commits focus.</strong> Open three windows, Alt+Tab twice, release, assert third window is focused.</li>
<li><strong>Close orb sends <code>WM_DELETE_WINDOW</code>.</strong> Click the red orb at the theme-defined coordinates; assert the client received the message (use a stub client that logs).</li>
<li><strong>Compositor open animation.</strong> Set <code>_GERSHWIN_WINDOW_OPEN_ANIMATION_RECT</code> on a yet-unmapped window, map it, screenshot at 50ms intervals, assert the window grows from the source rect.</li>
<li><strong>Long-running stability.</strong> Loop “open xterm, drag, close” 200 times; assert WM RSS is bounded and no zombie children.</li>
</ol>
<div class="panel">
<strong>Why not adopt <code>uitest</code> verbatim?</strong> It depends on a GNUstep
distributed-objects channel into the application under test. WindowManager
has no such channel today, and adding one is a non-trivial commitment.
For the WM we have a better oracle: the X server. Build the same Python
ergonomics (<code>scrot</code> on failure, fixture helpers, clear pass/fail) but
back the assertions with <code>wmctrl</code>/<code>xprop</code>/<code>xwininfo</code> output.
If we later want pixel-level checks, lift the <code>test_failure_capture.py</code>
helper from Workspace.
</div>
<!-- =========================== CI =========================== -->
<h2 id="ci">5. CI workflow</h2>
<p>
Drop <code>.github/workflows/ci.yml</code>. The same job pattern as <code>libs-base</code>
and <code>gershwin-workspace</code> already use; just add the X11 tooling.
</p>
<pre><code>name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install deps
run: |
sudo apt-get update
sudo apt-get install -y \
gnustep-make gnustep-base-runtime libgnustep-base-dev \
libgnustep-gui-dev libxcb1-dev libxcb-icccm4-dev \
libxcb-render0-dev libxcb-composite0-dev libxcb-damage0-dev \
libxcb-xfixes0-dev libxcb-shape0-dev libxcb-keysyms1-dev \
xvfb xdotool wmctrl x11-utils scrot
- name: Build
run: . /usr/share/GNUstep/Makefiles/GNUstep.sh && make
- name: Unit tests (tier 1)
run: . /usr/share/GNUstep/Makefiles/GNUstep.sh && make -C Tests check
- name: Integration tests (tier 2)
run: |
. /usr/share/GNUstep/Makefiles/GNUstep.sh
cd Tests/integration
for t in *.sh; do bash "$t" || exit 1; done
- name: UI tests (tier 3) — smoke subset
run: |
. /usr/share/GNUstep/Makefiles/GNUstep.sh
cd Tests/ui
python3 -m pytest -m smoke
- name: Upload failure screenshots
if: failure()
uses: actions/upload-artifact@v4
with: { name: ui-failures, path: /tmp/uitest_failures }
</code></pre>
<p>
Two scopes per PR: the unit + integration + smoke-UI suite (under ~3 minutes,
required to pass). Mark the full UI suite as <code>nightly</code> via a separate
workflow with <code>schedule:</code> — long stability tests don’t belong on the
PR critical path.
</p>
<p>Also add a small companion workflow: <code>.github/workflows/checklist.yml</code> that
posts the manual checklist as a sticky comment on every new PR, so authors see
it inline rather than buried in the PR template.</p>
<!-- =========================== ROLLOUT =========================== -->
<h2 id="rollout">6. Phased rollout</h2>
<div class="callout-row">
<div class="tag">Week 1</div>
<div>
<strong>Manual checklist live.</strong> Land
<code>.github/PULL_REQUEST_TEMPLATE.md</code> with sections 2.1–2.3.
Land the baseline doc (section 3) with current verified statuses.
Install <code>xserver-xephyr</code> on the maintainer machines so the
existing scripts run end-to-end.
</div>
</div>
<div class="callout-row">
<div class="tag">Week 2–3</div>
<div>
<strong>Tier 1 & CI skeleton.</strong> Add <code>Tests/</code> tree, write the
five unit test files listed in 4.1, wire <code>make check</code>, land the CI
workflow with build + unit only. First green PR using it sets the bar.
</div>
</div>
<div class="callout-row">
<div class="tag">Week 4–6</div>
<div>
<strong>Tier 2 integration.</strong> Add <code>Tests/integration/lib.sh</code> and
the seven X11 integration tests in 4.2 (each one retires one item from the
manual checklist). Required on PR.
</div>
</div>
<div class="callout-row">
<div class="tag">Week 7+</div>
<div>
<strong>Tier 3 UI driver.</strong> Stand up <code>Tests/ui/</code> with the seven
scenarios in 4.3. Smoke subset on PR; full suite nightly. As each UI test
proves stable, retire the matching manual checklist item.
</div>
</div>
<!-- =========================== MAPPING =========================== -->
<h2 id="mapping">7. Feature → test coverage map</h2>
<p>
This is the contract that lets us shrink the manual checklist over time.
For every feature area, we know what tier owns it.
</p>
<table>
<thead>
<tr><th>Feature area</th><th>Source</th><th>T1 unit</th><th>T2 integration</th><th>T3 UI</th><th>Manual</th></tr>
</thead>
<tbody>
<tr><td>Atom interning</td><td class="file">xcb/services/XCBAtomService.m</td><td>✓</td><td>—</td><td>—</td><td>—</td></tr>
<tr><td>EWMH atom set on root</td><td class="file">xcb/services/EWMHService.m</td><td>—</td><td>✓</td><td>—</td><td>spot-check</td></tr>
<tr><td>EWMH atom set on client</td><td class="file">xcb/services/EWMHService.m</td><td>—</td><td>✓</td><td>—</td><td>regr 2.2</td></tr>
<tr><td>Strut → workarea</td><td class="file">URSWorkareaManager.m</td><td>parser</td><td>✓</td><td>—</td><td>—</td></tr>
<tr><td>Frame extents math</td><td class="file">xcb/XCBFrame.m</td><td>✓</td><td>✓</td><td>—</td><td>—</td></tr>
<tr><td>Focus same-PID preference</td><td class="file">URSFocusManager.m</td><td>✓</td><td>—</td><td>✓</td><td>feature</td></tr>
<tr><td>X11 → AppKit activation mirror</td><td class="file">URSFocusManager.m</td><td>—</td><td>—</td><td>✓</td><td>feature</td></tr>
<tr><td>Drag preserves geometry</td><td class="file">XCBConnection.m, XCBFrame.m</td><td>—</td><td>—</td><td>✓</td><td>smoke</td></tr>
<tr><td>Resize 8 directions</td><td class="file">URSTitlebarController.m</td><td>hit-test</td><td>—</td><td>✓</td><td>feature</td></tr>
<tr><td>Snap zones & preview</td><td class="file">URSSnapPreviewOverlay.m</td><td>✓</td><td>—</td><td>✓</td><td>feature</td></tr>
<tr><td>Snap menu</td><td class="file">URSSnappingMenuController.m</td><td>—</td><td>—</td><td>✓</td><td>feature + regr</td></tr>
<tr><td>Alt+Tab cycle & commit</td><td class="file">URSWindowSwitcher.m, URSKeyboardManager.m</td><td>stack ops</td><td>—</td><td>✓</td><td>smoke + regr</td></tr>
<tr><td>Close / minimise / zoom orbs</td><td class="file">URSTitlebarController.m</td><td>hit-test</td><td>WM_DELETE</td><td>✓</td><td>smoke</td></tr>
<tr><td>Window position stability</td><td class="file">XCBConnection.m</td><td>—</td><td>✓</td><td>✓</td><td>regr</td></tr>
<tr><td>Compositor init & fallback</td><td class="file">URSCompositingManager.m</td><td>—</td><td>✓</td><td>—</td><td>smoke</td></tr>
<tr><td>Damage → repaint</td><td class="file">URSCompositingManager.m</td><td>—</td><td>—</td><td>✓</td><td>regr (Chromium scroll)</td></tr>
<tr><td>Open animation property</td><td class="file">XCBConnection.m, URSCompositingManager.m</td><td>parse</td><td>—</td><td>✓</td><td>compositor</td></tr>
<tr><td>Wedged-connection survival</td><td class="file">EWMHService.m, URSFocusManager.m</td><td>✓</td><td>✓</td><td>—</td><td>regr</td></tr>
<tr><td>SIGPIPE / signal handling</td><td class="file">main.m</td><td>—</td><td>✓</td><td>—</td><td>regr</td></tr>
<tr><td>WM_S0 takeover</td><td class="file">XCBSelection.m, URSHybridEventHandler.m</td><td>—</td><td>✓</td><td>—</td><td>—</td></tr>
</tbody>
</table>
<div class="footer-note">
Drafted from a deep audit of <code>/Developer/Library/Sources/gershwin-windowmanager</code> and the
surrounding Gershwin ecosystem (<code>libs-base/Tests</code> for the unit-test pattern,
<code>gershwin-workspace/Tools/uitest</code> for the UI-driver pattern). Concrete file
references in section 7 should be re-verified before each tier’s implementation, since
the codebase moves quickly.
</div>
</div>
</body>
</html>