test(app): cover override-redirect X11 windows end to end
- commit
- 47739da5abcf5530fa87af322525abbdda544a20
- author
- brice · · 2026-10-22 15:22 UTC
- committer
- brice · · 2026-10-22 17:54 UTC
- parent
- dc722bb
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +211 -8
M internal/app/testdata/x11probe/main.go +95 -4
| @@ -3,6 +3,11 @@ | |||
| 3 | 3 | // It opens one window filled with solid green, prints "mapped WxH" after the | |
| 4 | 4 | // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the | |
| 5 | 5 | // window manager sends WM_DELETE_WINDOW. | |
| 6 | + | // | |
| 7 | + | // With -override it also maps a magenta override-redirect window after the | |
| 8 | + | // main one is mapped (prints "override mapped") and unmaps it after | |
| 9 | + | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | |
| 10 | + | // it unmaps it instead when the main window loses focus (prints "dismissed"). | |
| 6 | 11 | package main | |
| 7 | 12 | ||
| 8 | 13 | import ( | |
| @@ -17,10 +22,14 @@ import ( | |||
| 17 | 22 | "golang.org/x/sys/unix" | |
| 18 | 23 | ||
| 19 | 24 | "bnema.dev/xturbo" | |
| 25 | + | "bnema.dev/xturbo/shape" | |
| 20 | 26 | "bnema.dev/xturbo/xproto" | |
| 21 | 27 | ) | |
| 22 | 28 | ||
| 23 | - | const green = 0x00ff00 | |
| 29 | + | const ( | |
| 30 | + | green = 0x00ff00 | |
| 31 | + | magenta = 0xff00ff | |
| 32 | + | ) | |
| 24 | 33 | ||
| 25 | 34 | func main() { | |
| 26 | 35 | if err := run(); err != nil { | |
| @@ -34,6 +43,13 @@ func run() error { | |||
| 34 | 43 | title := flag.String("title", "probe", "_NET_WM_NAME") | |
| 35 | 44 | size := flag.String("size", "300x200", "window size WxH") | |
| 36 | 45 | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | |
| 46 | + | override := flag.Bool("override", false, "map a magenta override-redirect window after the main one") | |
| 47 | + | overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates") | |
| 48 | + | overrideSize := flag.String("override-size", "120x80", "override window size WxH") | |
| 49 | + | overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)") | |
| 50 | + | overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip") | |
| 51 | + | overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape") | |
| 52 | + | dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus") | |
| 37 | 53 | flag.Parse() | |
| 38 | 54 | w, h, ok := parseSize(*size) | |
| 39 | 55 | if !ok { | |
| @@ -46,11 +62,13 @@ func run() error { | |||
| 46 | 62 | } | |
| 47 | 63 | defer c.Close() | |
| 48 | 64 | ||
| 49 | - | var atoms [4]uint32 | |
| 50 | - | if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil { | |
| 65 | + | var atoms [7]uint32 | |
| 66 | + | if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING", | |
| 67 | + | "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP"}, false, atoms[:]); err != nil { | |
| 51 | 68 | return err | |
| 52 | 69 | } | |
| 53 | 70 | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | |
| 71 | + | typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6] | |
| 54 | 72 | ||
| 55 | 73 | b := c.Buf() | |
| 56 | 74 | xwin, err := c.NewXID() | |
| @@ -84,6 +102,57 @@ func run() error { | |||
| 84 | 102 | time.Sleep(*sleep) | |
| 85 | 103 | xproto.MapWindow(b, win) | |
| 86 | 104 | ||
| 105 | + | // The override-redirect window is created once the main window is mapped. | |
| 106 | + | var ovWin xproto.Window | |
| 107 | + | var ovAt, ovSz [2]int | |
| 108 | + | if *override { | |
| 109 | + | var ok1, ok2 bool | |
| 110 | + | x, y, found := strings.Cut(*overrideAt, ",") | |
| 111 | + | ovAt[0], ovAt[1] = atoi(x), atoi(y) | |
| 112 | + | ok1 = found | |
| 113 | + | ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize) | |
| 114 | + | if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") { | |
| 115 | + | return fmt.Errorf("bad -override-at/-override-size/-override-type") | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | var ovDeadline time.Time // zero: no timed unmap pending | |
| 119 | + | unmapOverride := func(msg string) { | |
| 120 | + | if ovWin == 0 { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | xproto.UnmapWindow(b, ovWin) | |
| 124 | + | ovWin = 0 | |
| 125 | + | ovDeadline = time.Time{} | |
| 126 | + | fmt.Println(msg) | |
| 127 | + | } | |
| 128 | + | mapOverride := func() error { | |
| 129 | + | id, err := c.NewXID() | |
| 130 | + | if err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | ovWin = xproto.Window(id) | |
| 134 | + | var ov xproto.CreateWindowValues | |
| 135 | + | ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true) | |
| 136 | + | xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov) | |
| 137 | + | typ := typeMenu | |
| 138 | + | if *overrideType == "tooltip" { | |
| 139 | + | typ = typeTooltip | |
| 140 | + | } | |
| 141 | + | var tb [4]byte | |
| 142 | + | binary.LittleEndian.PutUint32(tb[:], typ) | |
| 143 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:]) | |
| 144 | + | if *overrideShape { | |
| 145 | + | shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil) | |
| 146 | + | } | |
| 147 | + | xproto.MapWindow(b, ovWin) | |
| 148 | + | fmt.Println("override mapped") | |
| 149 | + | if *overrideFor > 0 { | |
| 150 | + | ovDeadline = time.Now().Add(*overrideFor) | |
| 151 | + | } | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | var ovErr error | |
| 155 | + | ||
| 87 | 156 | mapped, done := false, false | |
| 88 | 157 | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | |
| 89 | 158 | hd := &xturbo.Handler{ | |
| @@ -100,9 +169,16 @@ func run() error { | |||
| 100 | 169 | if !mapped { | |
| 101 | 170 | mapped = true | |
| 102 | 171 | fmt.Printf("mapped %dx%d\n", cw, ch) | |
| 172 | + | if *override { | |
| 173 | + | ovErr = mapOverride() | |
| 174 | + | } | |
| 103 | 175 | } | |
| 104 | 176 | case xproto.KindFocusIn: | |
| 105 | 177 | fmt.Println("focus") | |
| 178 | + | case xproto.KindFocusOut: | |
| 179 | + | if *dismiss && xproto.FocusOutOf(ev).Event() == win { | |
| 180 | + | unmapOverride("dismissed") | |
| 181 | + | } | |
| 106 | 182 | case xproto.KindClientMessage: | |
| 107 | 183 | e := xproto.ClientMessageOf(ev) | |
| 108 | 184 | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | |
| @@ -119,13 +195,23 @@ func run() error { | |||
| 119 | 195 | } else if err != nil { | |
| 120 | 196 | return err | |
| 121 | 197 | } | |
| 198 | + | timeout := -1 | |
| 199 | + | if !ovDeadline.IsZero() { | |
| 200 | + | timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0) | |
| 201 | + | } | |
| 122 | 202 | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | |
| 123 | - | if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR { | |
| 203 | + | if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR { | |
| 124 | 204 | return err | |
| 125 | 205 | } | |
| 206 | + | if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) { | |
| 207 | + | unmapOverride("override unmapped") | |
| 208 | + | } | |
| 126 | 209 | if err := c.Dispatch(hd); err != nil { | |
| 127 | 210 | return err | |
| 128 | 211 | } | |
| 212 | + | if ovErr != nil { | |
| 213 | + | return ovErr | |
| 214 | + | } | |
| 129 | 215 | } | |
| 130 | 216 | return nil | |
| 131 | 217 | } | |
| @@ -139,3 +225,8 @@ func parseSize(s string) (w, h int, ok bool) { | |||
| 139 | 225 | h, err2 := strconv.Atoi(hs) | |
| 140 | 226 | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | |
| 141 | 227 | } | |
| 228 | + | ||
| 229 | + | func atoi(s string) int { | |
| 230 | + | n, _ := strconv.Atoi(s) | |
| 231 | + | return n | |
| 232 | + | } | |
| @@ -3,6 +3,11 @@ | |||||
| 3 | // It opens one window filled with solid green, prints "mapped WxH" after the | 3 | // It opens one window filled with solid green, prints "mapped WxH" after the | ||
| 4 | // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the | 4 | // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the | ||
| 5 | // window manager sends WM_DELETE_WINDOW. | 5 | // window manager sends WM_DELETE_WINDOW. | ||
| 6 | + | // | |||
| 7 | + | // With -override it also maps a magenta override-redirect window after the | |||
| 8 | + | // main one is mapped (prints "override mapped") and unmaps it after | |||
| 9 | + | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | |||
| 10 | + | // it unmaps it instead when the main window loses focus (prints "dismissed"). | |||
| 6 | package main | 11 | package main | ||
| 7 | 12 | ||||
| 8 | import ( | 13 | import ( | ||
| @@ -17,10 +22,14 @@ import ( | |||||
| 17 | "golang.org/x/sys/unix" | 22 | "golang.org/x/sys/unix" | ||
| 18 | 23 | ||||
| 19 | "bnema.dev/xturbo" | 24 | "bnema.dev/xturbo" | ||
| 25 | + | "bnema.dev/xturbo/shape" | |||
| 20 | "bnema.dev/xturbo/xproto" | 26 | "bnema.dev/xturbo/xproto" | ||
| 21 | ) | 27 | ) | ||
| 22 | 28 | ||||
| 23 | - | const green = 0x00ff00 | 29 | + | const ( |
| 30 | + | green = 0x00ff00 | |||
| 31 | + | magenta = 0xff00ff | |||
| 32 | + | ) | |||
| 24 | 33 | ||||
| 25 | func main() { | 34 | func main() { | ||
| 26 | if err := run(); err != nil { | 35 | if err := run(); err != nil { | ||
| @@ -34,6 +43,13 @@ func run() error { | |||||
| 34 | title := flag.String("title", "probe", "_NET_WM_NAME") | 43 | title := flag.String("title", "probe", "_NET_WM_NAME") | ||
| 35 | size := flag.String("size", "300x200", "window size WxH") | 44 | size := flag.String("size", "300x200", "window size WxH") | ||
| 36 | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | 45 | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | ||
| 46 | + | override := flag.Bool("override", false, "map a magenta override-redirect window after the main one") | |||
| 47 | + | overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates") | |||
| 48 | + | overrideSize := flag.String("override-size", "120x80", "override window size WxH") | |||
| 49 | + | overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)") | |||
| 50 | + | overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip") | |||
| 51 | + | overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape") | |||
| 52 | + | dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus") | |||
| 37 | flag.Parse() | 53 | flag.Parse() | ||
| 38 | w, h, ok := parseSize(*size) | 54 | w, h, ok := parseSize(*size) | ||
| 39 | if !ok { | 55 | if !ok { | ||
| @@ -46,11 +62,13 @@ func run() error { | |||||
| 46 | } | 62 | } | ||
| 47 | defer c.Close() | 63 | defer c.Close() | ||
| 48 | 64 | ||||
| 49 | - | var atoms [4]uint32 | 65 | + | var atoms [7]uint32 |
| 50 | - | if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil { | 66 | + | if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING", |
| 67 | + | "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP"}, false, atoms[:]); err != nil { | |||
| 51 | return err | 68 | return err | ||
| 52 | } | 69 | } | ||
| 53 | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | 70 | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | ||
| 71 | + | typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6] | |||
| 54 | 72 | ||||
| 55 | b := c.Buf() | 73 | b := c.Buf() | ||
| 56 | xwin, err := c.NewXID() | 74 | xwin, err := c.NewXID() | ||
| @@ -84,6 +102,57 @@ func run() error { | |||||
| 84 | time.Sleep(*sleep) | 102 | time.Sleep(*sleep) | ||
| 85 | xproto.MapWindow(b, win) | 103 | xproto.MapWindow(b, win) | ||
| 86 | 104 | ||||
| 105 | + | // The override-redirect window is created once the main window is mapped. | |||
| 106 | + | var ovWin xproto.Window | |||
| 107 | + | var ovAt, ovSz [2]int | |||
| 108 | + | if *override { | |||
| 109 | + | var ok1, ok2 bool | |||
| 110 | + | x, y, found := strings.Cut(*overrideAt, ",") | |||
| 111 | + | ovAt[0], ovAt[1] = atoi(x), atoi(y) | |||
| 112 | + | ok1 = found | |||
| 113 | + | ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize) | |||
| 114 | + | if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") { | |||
| 115 | + | return fmt.Errorf("bad -override-at/-override-size/-override-type") | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | var ovDeadline time.Time // zero: no timed unmap pending | |||
| 119 | + | unmapOverride := func(msg string) { | |||
| 120 | + | if ovWin == 0 { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | xproto.UnmapWindow(b, ovWin) | |||
| 124 | + | ovWin = 0 | |||
| 125 | + | ovDeadline = time.Time{} | |||
| 126 | + | fmt.Println(msg) | |||
| 127 | + | } | |||
| 128 | + | mapOverride := func() error { | |||
| 129 | + | id, err := c.NewXID() | |||
| 130 | + | if err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | ovWin = xproto.Window(id) | |||
| 134 | + | var ov xproto.CreateWindowValues | |||
| 135 | + | ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true) | |||
| 136 | + | xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov) | |||
| 137 | + | typ := typeMenu | |||
| 138 | + | if *overrideType == "tooltip" { | |||
| 139 | + | typ = typeTooltip | |||
| 140 | + | } | |||
| 141 | + | var tb [4]byte | |||
| 142 | + | binary.LittleEndian.PutUint32(tb[:], typ) | |||
| 143 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:]) | |||
| 144 | + | if *overrideShape { | |||
| 145 | + | shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil) | |||
| 146 | + | } | |||
| 147 | + | xproto.MapWindow(b, ovWin) | |||
| 148 | + | fmt.Println("override mapped") | |||
| 149 | + | if *overrideFor > 0 { | |||
| 150 | + | ovDeadline = time.Now().Add(*overrideFor) | |||
| 151 | + | } | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | var ovErr error | |||
| 155 | + | ||||
| 87 | mapped, done := false, false | 156 | mapped, done := false, false | ||
| 88 | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | 157 | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | ||
| 89 | hd := &xturbo.Handler{ | 158 | hd := &xturbo.Handler{ | ||
| @@ -100,9 +169,16 @@ func run() error { | |||||
| 100 | if !mapped { | 169 | if !mapped { | ||
| 101 | mapped = true | 170 | mapped = true | ||
| 102 | fmt.Printf("mapped %dx%d\n", cw, ch) | 171 | fmt.Printf("mapped %dx%d\n", cw, ch) | ||
| 172 | + | if *override { | |||
| 173 | + | ovErr = mapOverride() | |||
| 174 | + | } | |||
| 103 | } | 175 | } | ||
| 104 | case xproto.KindFocusIn: | 176 | case xproto.KindFocusIn: | ||
| 105 | fmt.Println("focus") | 177 | fmt.Println("focus") | ||
| 178 | + | case xproto.KindFocusOut: | |||
| 179 | + | if *dismiss && xproto.FocusOutOf(ev).Event() == win { | |||
| 180 | + | unmapOverride("dismissed") | |||
| 181 | + | } | |||
| 106 | case xproto.KindClientMessage: | 182 | case xproto.KindClientMessage: | ||
| 107 | e := xproto.ClientMessageOf(ev) | 183 | e := xproto.ClientMessageOf(ev) | ||
| 108 | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | 184 | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | ||
| @@ -119,13 +195,23 @@ func run() error { | |||||
| 119 | } else if err != nil { | 195 | } else if err != nil { | ||
| 120 | return err | 196 | return err | ||
| 121 | } | 197 | } | ||
| 198 | + | timeout := -1 | |||
| 199 | + | if !ovDeadline.IsZero() { | |||
| 200 | + | timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0) | |||
| 201 | + | } | |||
| 122 | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | 202 | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | ||
| 123 | - | if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR { | 203 | + | if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR { |
| 124 | return err | 204 | return err | ||
| 125 | } | 205 | } | ||
| 206 | + | if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) { | |||
| 207 | + | unmapOverride("override unmapped") | |||
| 208 | + | } | |||
| 126 | if err := c.Dispatch(hd); err != nil { | 209 | if err := c.Dispatch(hd); err != nil { | ||
| 127 | return err | 210 | return err | ||
| 128 | } | 211 | } | ||
| 212 | + | if ovErr != nil { | |||
| 213 | + | return ovErr | |||
| 214 | + | } | |||
| 129 | } | 215 | } | ||
| 130 | return nil | 216 | return nil | ||
| 131 | } | 217 | } | ||
| @@ -139,3 +225,8 @@ func parseSize(s string) (w, h int, ok bool) { | |||||
| 139 | h, err2 := strconv.Atoi(hs) | 225 | h, err2 := strconv.Atoi(hs) | ||
| 140 | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | 226 | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | ||
| 141 | } | 227 | } | ||
| 228 | + | ||||
| 229 | + | func atoi(s string) int { | |||
| 230 | + | n, _ := strconv.Atoi(s) | |||
| 231 | + | return n | |||
| 232 | + | } | |||
M internal/app/xwayland_test.go +116 -4
| @@ -41,10 +41,17 @@ type probe struct { | |||
| 41 | 41 | waited chan error | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | - | // TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows | |
| 45 | - | // up in the state with its class and pid, is drawn, tiles next to a second | |
| 46 | - | // one, receives focus and is closed by the close-window bind. | |
| 47 | - | func TestXwaylandProbe(t *testing.T) { | |
| 44 | + | // xwaylandEnv is a running headless compositor with the built-in XWM. | |
| 45 | + | type xwaylandEnv struct { | |
| 46 | + | bin, display, statePath, shots string | |
| 47 | + | script *io.PipeWriter | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // startXwayland runs the compositor on one headless output and waits for its | |
| 51 | + | // X11 display. It skips the test without Xwayland and stops everything when | |
| 52 | + | // the test ends. | |
| 53 | + | func startXwayland(t *testing.T) *xwaylandEnv { | |
| 54 | + | t.Helper() | |
| 48 | 55 | if _, err := exec.LookPath("Xwayland"); err != nil { | |
| 49 | 56 | t.Skip("Xwayland not on PATH") | |
| 50 | 57 | } | |
| @@ -101,7 +108,15 @@ func TestXwaylandProbe(t *testing.T) { | |||
| 101 | 108 | if err != nil { | |
| 102 | 109 | t.Fatal(err) | |
| 103 | 110 | } | |
| 111 | + | return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW} | |
| 112 | + | } | |
| 104 | 113 | ||
| 114 | + | // TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows | |
| 115 | + | // up in the state with its class and pid, is drawn, tiles next to a second | |
| 116 | + | // one, receives focus and is closed by the close-window bind. | |
| 117 | + | func TestXwaylandProbe(t *testing.T) { | |
| 118 | + | e := startXwayland(t) | |
| 119 | + | bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script | |
| 105 | 120 | first := startProbe(t, bin, display, "-title", "one") | |
| 106 | 121 | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | |
| 107 | 122 | ||
| @@ -280,3 +295,100 @@ func greenShare(path string, r image.Rectangle) float64 { | |||
| 280 | 295 | } | |
| 281 | 296 | return float64(n) / float64(r.Dx()*r.Dy()) | |
| 282 | 297 | } | |
| 298 | + | ||
| 299 | + | // magentaShare is the fraction of pixels of r in the PNG at path that are | |
| 300 | + | // clearly magenta; 0 when the image cannot be read yet. | |
| 301 | + | func magentaShare(path string, r image.Rectangle) float64 { | |
| 302 | + | f, err := os.Open(path) | |
| 303 | + | if err != nil { | |
| 304 | + | return 0 | |
| 305 | + | } | |
| 306 | + | defer f.Close() | |
| 307 | + | img, err := png.Decode(f) | |
| 308 | + | if err != nil { | |
| 309 | + | return 0 | |
| 310 | + | } | |
| 311 | + | r = r.Intersect(img.Bounds()) | |
| 312 | + | if r.Empty() { | |
| 313 | + | return 0 | |
| 314 | + | } | |
| 315 | + | n := 0 | |
| 316 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |
| 317 | + | for x := r.Min.X; x < r.Max.X; x++ { | |
| 318 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |
| 319 | + | if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 { | |
| 320 | + | n++ | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // TestXwaylandOverride: an override-redirect window (a menu) of an X11 client | |
| 328 | + | // is drawn above the toplevel at its X position, is not a window in the | |
| 329 | + | // state, leaves keyboard focus on the toplevel and vanishes when unmapped. | |
| 330 | + | func TestXwaylandOverride(t *testing.T) { | |
| 331 | + | e := startXwayland(t) | |
| 332 | + | // Probe unmaps the menu 3 s after mapping it; the first checks run well | |
| 333 | + | // inside that window. | |
| 334 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50", | |
| 335 | + | "-override-size", "120x80", "-override-for", "3s") | |
| 336 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |
| 337 | + | shot := filepath.Join(e.shots, "latest.png") | |
| 338 | + | box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin | |
| 339 | + | until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 }) | |
| 340 | + | ||
| 341 | + | var st statefile.State | |
| 342 | + | until(t, "focused X11Probe toplevel", func() bool { | |
| 343 | + | st = readState(e.statePath) | |
| 344 | + | return len(st.Windows) >= 1 && st.Window != nil | |
| 345 | + | }) | |
| 346 | + | if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" { | |
| 347 | + | t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows) | |
| 348 | + | } | |
| 349 | + | id := st.Windows[0].ID | |
| 350 | + | if st.Window.ID != id { | |
| 351 | + | t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id) | |
| 352 | + | } | |
| 353 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |
| 354 | + | ||
| 355 | + | // The menu is above the toplevel: its box is magenta, not green. | |
| 356 | + | if g := greenShare(shot, box); g > 0.1 { | |
| 357 | + | t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g) | |
| 358 | + | } | |
| 359 | + | until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") }) | |
| 360 | + | until(t, "menu gone from latest.png", func() bool { | |
| 361 | + | return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9 | |
| 362 | + | }) | |
| 363 | + | if st := readState(e.statePath); st.Window == nil || st.Window.ID != id { | |
| 364 | + | t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id) | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel | |
| 369 | + | // when core focuses another window, which is how toolkits close their menus. | |
| 370 | + | func TestXwaylandOverrideDismiss(t *testing.T) { | |
| 371 | + | e := startXwayland(t) | |
| 372 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout") | |
| 373 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |
| 374 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |
| 375 | + | // A second X11 window takes the focus away from the menu owner. | |
| 376 | + | startProbe(t, e.bin, e.display, "-title", "other") | |
| 377 | + | until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") }) | |
| 378 | + | box := image.Rect(100, 50, 220, 130) | |
| 379 | + | until(t, "menu gone from latest.png", func() bool { | |
| 380 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0 | |
| 381 | + | }) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // TestXwaylandOverrideShape: a menu with an empty input shape is still drawn | |
| 385 | + | // and still not a window. | |
| 386 | + | func TestXwaylandOverrideShape(t *testing.T) { | |
| 387 | + | e := startXwayland(t) | |
| 388 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip") | |
| 389 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |
| 390 | + | until(t, "magenta tooltip in latest.png", func() bool { | |
| 391 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9 | |
| 392 | + | }) | |
| 393 | + | until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 }) | |
| 394 | + | } | |
| @@ -41,10 +41,17 @@ type probe struct { | |||||
| 41 | waited chan error | 41 | waited chan error | ||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | - | // TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows | 44 | + | // xwaylandEnv is a running headless compositor with the built-in XWM. |
| 45 | - | // up in the state with its class and pid, is drawn, tiles next to a second | 45 | + | type xwaylandEnv struct { |
| 46 | - | // one, receives focus and is closed by the close-window bind. | 46 | + | bin, display, statePath, shots string |
| 47 | - | func TestXwaylandProbe(t *testing.T) { | 47 | + | script *io.PipeWriter |
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // startXwayland runs the compositor on one headless output and waits for its | |||
| 51 | + | // X11 display. It skips the test without Xwayland and stops everything when | |||
| 52 | + | // the test ends. | |||
| 53 | + | func startXwayland(t *testing.T) *xwaylandEnv { | |||
| 54 | + | t.Helper() | |||
| 48 | if _, err := exec.LookPath("Xwayland"); err != nil { | 55 | if _, err := exec.LookPath("Xwayland"); err != nil { | ||
| 49 | t.Skip("Xwayland not on PATH") | 56 | t.Skip("Xwayland not on PATH") | ||
| 50 | } | 57 | } | ||
| @@ -101,7 +108,15 @@ func TestXwaylandProbe(t *testing.T) { | |||||
| 101 | if err != nil { | 108 | if err != nil { | ||
| 102 | t.Fatal(err) | 109 | t.Fatal(err) | ||
| 103 | } | 110 | } | ||
| 111 | + | return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW} | |||
| 112 | + | } | |||
| 104 | 113 | ||||
| 114 | + | // TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows | |||
| 115 | + | // up in the state with its class and pid, is drawn, tiles next to a second | |||
| 116 | + | // one, receives focus and is closed by the close-window bind. | |||
| 117 | + | func TestXwaylandProbe(t *testing.T) { | |||
| 118 | + | e := startXwayland(t) | |||
| 119 | + | bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script | |||
| 105 | first := startProbe(t, bin, display, "-title", "one") | 120 | first := startProbe(t, bin, display, "-title", "one") | ||
| 106 | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | 121 | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | ||
| 107 | 122 | ||||
| @@ -280,3 +295,100 @@ func greenShare(path string, r image.Rectangle) float64 { | |||||
| 280 | } | 295 | } | ||
| 281 | return float64(n) / float64(r.Dx()*r.Dy()) | 296 | return float64(n) / float64(r.Dx()*r.Dy()) | ||
| 282 | } | 297 | } | ||
| 298 | + | ||||
| 299 | + | // magentaShare is the fraction of pixels of r in the PNG at path that are | |||
| 300 | + | // clearly magenta; 0 when the image cannot be read yet. | |||
| 301 | + | func magentaShare(path string, r image.Rectangle) float64 { | |||
| 302 | + | f, err := os.Open(path) | |||
| 303 | + | if err != nil { | |||
| 304 | + | return 0 | |||
| 305 | + | } | |||
| 306 | + | defer f.Close() | |||
| 307 | + | img, err := png.Decode(f) | |||
| 308 | + | if err != nil { | |||
| 309 | + | return 0 | |||
| 310 | + | } | |||
| 311 | + | r = r.Intersect(img.Bounds()) | |||
| 312 | + | if r.Empty() { | |||
| 313 | + | return 0 | |||
| 314 | + | } | |||
| 315 | + | n := 0 | |||
| 316 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 317 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 318 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |||
| 319 | + | if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 { | |||
| 320 | + | n++ | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | // TestXwaylandOverride: an override-redirect window (a menu) of an X11 client | |||
| 328 | + | // is drawn above the toplevel at its X position, is not a window in the | |||
| 329 | + | // state, leaves keyboard focus on the toplevel and vanishes when unmapped. | |||
| 330 | + | func TestXwaylandOverride(t *testing.T) { | |||
| 331 | + | e := startXwayland(t) | |||
| 332 | + | // Probe unmaps the menu 3 s after mapping it; the first checks run well | |||
| 333 | + | // inside that window. | |||
| 334 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50", | |||
| 335 | + | "-override-size", "120x80", "-override-for", "3s") | |||
| 336 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 337 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 338 | + | box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin | |||
| 339 | + | until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 }) | |||
| 340 | + | ||||
| 341 | + | var st statefile.State | |||
| 342 | + | until(t, "focused X11Probe toplevel", func() bool { | |||
| 343 | + | st = readState(e.statePath) | |||
| 344 | + | return len(st.Windows) >= 1 && st.Window != nil | |||
| 345 | + | }) | |||
| 346 | + | if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" { | |||
| 347 | + | t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows) | |||
| 348 | + | } | |||
| 349 | + | id := st.Windows[0].ID | |||
| 350 | + | if st.Window.ID != id { | |||
| 351 | + | t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id) | |||
| 352 | + | } | |||
| 353 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 354 | + | ||||
| 355 | + | // The menu is above the toplevel: its box is magenta, not green. | |||
| 356 | + | if g := greenShare(shot, box); g > 0.1 { | |||
| 357 | + | t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g) | |||
| 358 | + | } | |||
| 359 | + | until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") }) | |||
| 360 | + | until(t, "menu gone from latest.png", func() bool { | |||
| 361 | + | return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9 | |||
| 362 | + | }) | |||
| 363 | + | if st := readState(e.statePath); st.Window == nil || st.Window.ID != id { | |||
| 364 | + | t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id) | |||
| 365 | + | } | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | // TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel | |||
| 369 | + | // when core focuses another window, which is how toolkits close their menus. | |||
| 370 | + | func TestXwaylandOverrideDismiss(t *testing.T) { | |||
| 371 | + | e := startXwayland(t) | |||
| 372 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout") | |||
| 373 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 374 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 375 | + | // A second X11 window takes the focus away from the menu owner. | |||
| 376 | + | startProbe(t, e.bin, e.display, "-title", "other") | |||
| 377 | + | until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") }) | |||
| 378 | + | box := image.Rect(100, 50, 220, 130) | |||
| 379 | + | until(t, "menu gone from latest.png", func() bool { | |||
| 380 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0 | |||
| 381 | + | }) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // TestXwaylandOverrideShape: a menu with an empty input shape is still drawn | |||
| 385 | + | // and still not a window. | |||
| 386 | + | func TestXwaylandOverrideShape(t *testing.T) { | |||
| 387 | + | e := startXwayland(t) | |||
| 388 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip") | |||
| 389 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 390 | + | until(t, "magenta tooltip in latest.png", func() bool { | |||
| 391 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9 | |||
| 392 | + | }) | |||
| 393 | + | until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 }) | |||
| 394 | + | } | |||