test(app): add headless Xwayland end-to-end test with an x11probe client
- commit
- b64a2071901ea81ab2ac98726bd98fa960b707a8
- author
- brice · · 2026-10-22 13:59 UTC
- committer
- brice · · 2026-10-22 17:54 UTC
- parent
- ace7dd1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +430 -1
M Makefile +7 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | .PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install | |
| 1 | + | .PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install | |
| 2 | 2 | ||
| 3 | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | |
| 4 | 4 | TTY_TIMEOUT ?= 0 | |
| @@ -26,6 +26,7 @@ test: | |||
| 26 | 26 | CGO_ENABLED=0 go test ./... | |
| 27 | 27 | vet: | |
| 28 | 28 | CGO_ENABLED=0 go vet ./... | |
| 29 | + | CGO_ENABLED=0 go vet -tags xwayland ./internal/app | |
| 29 | 30 | race: | |
| 30 | 31 | # cgo is enabled only for the race test binary. | |
| 31 | 32 | CGO_ENABLED=1 go test -race ./... | |
| @@ -78,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check | |||
| 78 | 79 | color-check: | |
| 79 | 80 | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | |
| 80 | 81 | ||
| 82 | + | # End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a | |
| 83 | + | # headless NeferWL. Needs Xwayland and a GPU; not part of check. | |
| 84 | + | e2e-xwayland: | |
| 85 | + | CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v | |
| 86 | + | ||
| 81 | 87 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | |
| 82 | 88 | # come from the module proxy in prepare(); the build itself runs offline. | |
| 83 | 89 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | .PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install | 1 | + | .PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install |
| 2 | 2 | ||||
| 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | ||
| 4 | TTY_TIMEOUT ?= 0 | 4 | TTY_TIMEOUT ?= 0 | ||
| @@ -26,6 +26,7 @@ test: | |||||
| 26 | CGO_ENABLED=0 go test ./... | 26 | CGO_ENABLED=0 go test ./... | ||
| 27 | vet: | 27 | vet: | ||
| 28 | CGO_ENABLED=0 go vet ./... | 28 | CGO_ENABLED=0 go vet ./... | ||
| 29 | + | CGO_ENABLED=0 go vet -tags xwayland ./internal/app | |||
| 29 | race: | 30 | race: | ||
| 30 | # cgo is enabled only for the race test binary. | 31 | # cgo is enabled only for the race test binary. | ||
| 31 | CGO_ENABLED=1 go test -race ./... | 32 | CGO_ENABLED=1 go test -race ./... | ||
| @@ -78,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check | |||||
| 78 | color-check: | 79 | color-check: | ||
| 79 | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | 80 | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | ||
| 80 | 81 | ||||
| 82 | + | # End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a | |||
| 83 | + | # headless NeferWL. Needs Xwayland and a GPU; not part of check. | |||
| 84 | + | e2e-xwayland: | |||
| 85 | + | CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v | |||
| 86 | + | ||||
| 81 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 87 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | ||
| 82 | # come from the module proxy in prepare(); the build itself runs offline. | 88 | # come from the module proxy in prepare(); the build itself runs offline. | ||
| 83 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | 89 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | ||
A internal/app/testdata/x11probe/main.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | // Command x11probe is a minimal X11 client for the Xwayland end-to-end test. | |
| 2 | + | // | |
| 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 | |
| 5 | + | // window manager sends WM_DELETE_WINDOW. | |
| 6 | + | package main | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "encoding/binary" | |
| 10 | + | "flag" | |
| 11 | + | "fmt" | |
| 12 | + | "os" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "golang.org/x/sys/unix" | |
| 18 | + | ||
| 19 | + | "bnema.dev/xturbo" | |
| 20 | + | "bnema.dev/xturbo/xproto" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const green = 0x00ff00 | |
| 24 | + | ||
| 25 | + | func main() { | |
| 26 | + | if err := run(); err != nil { | |
| 27 | + | fmt.Fprintln(os.Stderr, "x11probe:", err) | |
| 28 | + | os.Exit(1) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func run() error { | |
| 33 | + | class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)") | |
| 34 | + | title := flag.String("title", "probe", "_NET_WM_NAME") | |
| 35 | + | size := flag.String("size", "300x200", "window size WxH") | |
| 36 | + | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | |
| 37 | + | flag.Parse() | |
| 38 | + | w, h, ok := parseSize(*size) | |
| 39 | + | if !ok { | |
| 40 | + | return fmt.Errorf("bad -size %q", *size) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | c, err := xturbo.Dial("") // $DISPLAY | |
| 44 | + | if err != nil { | |
| 45 | + | return err | |
| 46 | + | } | |
| 47 | + | defer c.Close() | |
| 48 | + | ||
| 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 { | |
| 51 | + | return err | |
| 52 | + | } | |
| 53 | + | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | |
| 54 | + | ||
| 55 | + | b := c.Buf() | |
| 56 | + | xwin, err := c.NewXID() | |
| 57 | + | if err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | xgc, err := c.NewXID() | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | win, gc := xproto.Window(xwin), xproto.Gcontext(xgc) | |
| 65 | + | ||
| 66 | + | var v xproto.CreateWindowValues | |
| 67 | + | v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange) | |
| 68 | + | xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v) | |
| 69 | + | ||
| 70 | + | wmClass := []byte("x11probe\x00" + *class + "\x00") | |
| 71 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass) | |
| 72 | + | name := []byte(*title) | |
| 73 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name) | |
| 74 | + | var del [4]byte | |
| 75 | + | binary.LittleEndian.PutUint32(del[:], uint32(deleteWin)) | |
| 76 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:]) | |
| 77 | + | ||
| 78 | + | var gv xproto.CreateGCValues | |
| 79 | + | gv.SetForeground(green) | |
| 80 | + | xproto.CreateGC(b, gc, xproto.Drawable(win), &gv) | |
| 81 | + | if err := c.Flush(); err != nil { | |
| 82 | + | return err | |
| 83 | + | } | |
| 84 | + | time.Sleep(*sleep) | |
| 85 | + | xproto.MapWindow(b, win) | |
| 86 | + | ||
| 87 | + | mapped, done := false, false | |
| 88 | + | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | |
| 89 | + | hd := &xturbo.Handler{ | |
| 90 | + | OnEvent: func(ev *xturbo.Event) { | |
| 91 | + | switch ev.Kind { | |
| 92 | + | case xproto.KindExpose: | |
| 93 | + | xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}}) | |
| 94 | + | case xproto.KindConfigureNotify: | |
| 95 | + | e := xproto.ConfigureNotifyOf(ev) | |
| 96 | + | if e.Window() != win { | |
| 97 | + | break | |
| 98 | + | } | |
| 99 | + | cw, ch = e.Width(), e.Height() | |
| 100 | + | if !mapped { | |
| 101 | + | mapped = true | |
| 102 | + | fmt.Printf("mapped %dx%d\n", cw, ch) | |
| 103 | + | } | |
| 104 | + | case xproto.KindFocusIn: | |
| 105 | + | fmt.Println("focus") | |
| 106 | + | case xproto.KindClientMessage: | |
| 107 | + | e := xproto.ClientMessageOf(ev) | |
| 108 | + | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | |
| 109 | + | done = true | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | }, | |
| 113 | + | OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) }, | |
| 114 | + | } | |
| 115 | + | for !done { | |
| 116 | + | events := int16(unix.POLLIN) | |
| 117 | + | if err := c.Flush(); err == xturbo.ErrWouldBlock { | |
| 118 | + | events |= unix.POLLOUT | |
| 119 | + | } else if err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | |
| 123 | + | if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | if err := c.Dispatch(hd); err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func parseSize(s string) (w, h int, ok bool) { | |
| 134 | + | ws, hs, found := strings.Cut(s, "x") | |
| 135 | + | if !found { | |
| 136 | + | return 0, 0, false | |
| 137 | + | } | |
| 138 | + | w, err1 := strconv.Atoi(ws) | |
| 139 | + | h, err2 := strconv.Atoi(hs) | |
| 140 | + | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | // Command x11probe is a minimal X11 client for the Xwayland end-to-end test. | |||
| 2 | + | // | |||
| 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 | |||
| 5 | + | // window manager sends WM_DELETE_WINDOW. | |||
| 6 | + | package main | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "encoding/binary" | |||
| 10 | + | "flag" | |||
| 11 | + | "fmt" | |||
| 12 | + | "os" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "golang.org/x/sys/unix" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/xturbo" | |||
| 20 | + | "bnema.dev/xturbo/xproto" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | const green = 0x00ff00 | |||
| 24 | + | ||||
| 25 | + | func main() { | |||
| 26 | + | if err := run(); err != nil { | |||
| 27 | + | fmt.Fprintln(os.Stderr, "x11probe:", err) | |||
| 28 | + | os.Exit(1) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func run() error { | |||
| 33 | + | class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)") | |||
| 34 | + | title := flag.String("title", "probe", "_NET_WM_NAME") | |||
| 35 | + | size := flag.String("size", "300x200", "window size WxH") | |||
| 36 | + | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | |||
| 37 | + | flag.Parse() | |||
| 38 | + | w, h, ok := parseSize(*size) | |||
| 39 | + | if !ok { | |||
| 40 | + | return fmt.Errorf("bad -size %q", *size) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | c, err := xturbo.Dial("") // $DISPLAY | |||
| 44 | + | if err != nil { | |||
| 45 | + | return err | |||
| 46 | + | } | |||
| 47 | + | defer c.Close() | |||
| 48 | + | ||||
| 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 { | |||
| 51 | + | return err | |||
| 52 | + | } | |||
| 53 | + | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | |||
| 54 | + | ||||
| 55 | + | b := c.Buf() | |||
| 56 | + | xwin, err := c.NewXID() | |||
| 57 | + | if err != nil { | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | xgc, err := c.NewXID() | |||
| 61 | + | if err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | win, gc := xproto.Window(xwin), xproto.Gcontext(xgc) | |||
| 65 | + | ||||
| 66 | + | var v xproto.CreateWindowValues | |||
| 67 | + | v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange) | |||
| 68 | + | xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v) | |||
| 69 | + | ||||
| 70 | + | wmClass := []byte("x11probe\x00" + *class + "\x00") | |||
| 71 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass) | |||
| 72 | + | name := []byte(*title) | |||
| 73 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name) | |||
| 74 | + | var del [4]byte | |||
| 75 | + | binary.LittleEndian.PutUint32(del[:], uint32(deleteWin)) | |||
| 76 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:]) | |||
| 77 | + | ||||
| 78 | + | var gv xproto.CreateGCValues | |||
| 79 | + | gv.SetForeground(green) | |||
| 80 | + | xproto.CreateGC(b, gc, xproto.Drawable(win), &gv) | |||
| 81 | + | if err := c.Flush(); err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | time.Sleep(*sleep) | |||
| 85 | + | xproto.MapWindow(b, win) | |||
| 86 | + | ||||
| 87 | + | mapped, done := false, false | |||
| 88 | + | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | |||
| 89 | + | hd := &xturbo.Handler{ | |||
| 90 | + | OnEvent: func(ev *xturbo.Event) { | |||
| 91 | + | switch ev.Kind { | |||
| 92 | + | case xproto.KindExpose: | |||
| 93 | + | xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}}) | |||
| 94 | + | case xproto.KindConfigureNotify: | |||
| 95 | + | e := xproto.ConfigureNotifyOf(ev) | |||
| 96 | + | if e.Window() != win { | |||
| 97 | + | break | |||
| 98 | + | } | |||
| 99 | + | cw, ch = e.Width(), e.Height() | |||
| 100 | + | if !mapped { | |||
| 101 | + | mapped = true | |||
| 102 | + | fmt.Printf("mapped %dx%d\n", cw, ch) | |||
| 103 | + | } | |||
| 104 | + | case xproto.KindFocusIn: | |||
| 105 | + | fmt.Println("focus") | |||
| 106 | + | case xproto.KindClientMessage: | |||
| 107 | + | e := xproto.ClientMessageOf(ev) | |||
| 108 | + | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | |||
| 109 | + | done = true | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | }, | |||
| 113 | + | OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) }, | |||
| 114 | + | } | |||
| 115 | + | for !done { | |||
| 116 | + | events := int16(unix.POLLIN) | |||
| 117 | + | if err := c.Flush(); err == xturbo.ErrWouldBlock { | |||
| 118 | + | events |= unix.POLLOUT | |||
| 119 | + | } else if err != nil { | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | |||
| 123 | + | if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | if err := c.Dispatch(hd); err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func parseSize(s string) (w, h int, ok bool) { | |||
| 134 | + | ws, hs, found := strings.Cut(s, "x") | |||
| 135 | + | if !found { | |||
| 136 | + | return 0, 0, false | |||
| 137 | + | } | |||
| 138 | + | w, err1 := strconv.Atoi(ws) | |||
| 139 | + | h, err2 := strconv.Atoi(hs) | |||
| 140 | + | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | |||
| 141 | + | } | |||
A internal/app/xwayland_test.go +282 -0
| @@ -0,0 +1,282 @@ | |||
| 1 | + | //go:build xwayland | |
| 2 | + | ||
| 3 | + | // End-to-end test of the built-in XWM: a real Xwayland and a real X11 client | |
| 4 | + | // (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on | |
| 5 | + | // PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with | |
| 6 | + | // `make e2e-xwayland`. | |
| 7 | + | package app | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "context" | |
| 11 | + | "image" | |
| 12 | + | "image/png" | |
| 13 | + | "io" | |
| 14 | + | "os" | |
| 15 | + | "os/exec" | |
| 16 | + | "path/filepath" | |
| 17 | + | "regexp" | |
| 18 | + | "strings" | |
| 19 | + | "sync" | |
| 20 | + | "testing" | |
| 21 | + | "time" | |
| 22 | + | ||
| 23 | + | "github.com/bnema/zerowrap" | |
| 24 | + | ||
| 25 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 26 | + | "github.com/bnema/neferwl/internal/adapters/statefile" | |
| 27 | + | "github.com/bnema/neferwl/internal/ports" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | const ( | |
| 31 | + | xwaylandTimeout = 10 * time.Second | |
| 32 | + | xwaylandW, xwaylandH = 640, 360 | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`) | |
| 36 | + | ||
| 37 | + | // probe is a running x11probe. | |
| 38 | + | type probe struct { | |
| 39 | + | cmd *exec.Cmd | |
| 40 | + | out *lockedBuffer | |
| 41 | + | waited chan error | |
| 42 | + | } | |
| 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) { | |
| 48 | + | if _, err := exec.LookPath("Xwayland"); err != nil { | |
| 49 | + | t.Skip("Xwayland not on PATH") | |
| 50 | + | } | |
| 51 | + | // The compositor must not read or write the live session's state. | |
| 52 | + | for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} { | |
| 53 | + | if v, ok := os.LookupEnv(k); ok { | |
| 54 | + | os.Unsetenv(k) | |
| 55 | + | t.Cleanup(func() { os.Setenv(k, v) }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | runtime := t.TempDir() | |
| 59 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |
| 60 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| 61 | + | shots := t.TempDir() | |
| 62 | + | bin := buildProbe(t) | |
| 63 | + | ||
| 64 | + | script, scriptW := io.Pipe() | |
| 65 | + | logs := &lockedBuffer{} | |
| 66 | + | ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs}))) | |
| 67 | + | cfg := config.Defaults() | |
| 68 | + | cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"} | |
| 69 | + | done := make(chan error, 1) | |
| 70 | + | go func() { | |
| 71 | + | done <- Run(ctx, Options{ | |
| 72 | + | Backend: "headless", NoTerminal: true, Config: cfg, Script: script, | |
| 73 | + | ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}}, | |
| 74 | + | }) | |
| 75 | + | }() | |
| 76 | + | t.Cleanup(func() { | |
| 77 | + | if t.Failed() { | |
| 78 | + | t.Logf("compositor log:\n%s", logs.String()) | |
| 79 | + | } | |
| 80 | + | cancel() | |
| 81 | + | _ = scriptW.Close() | |
| 82 | + | select { | |
| 83 | + | case err := <-done: | |
| 84 | + | if err != nil { | |
| 85 | + | t.Errorf("Run: %v", err) | |
| 86 | + | } | |
| 87 | + | case <-time.After(5 * time.Second): | |
| 88 | + | t.Error("Run did not stop") | |
| 89 | + | } | |
| 90 | + | }) | |
| 91 | + | ||
| 92 | + | var display string | |
| 93 | + | until(t, "X11 display in the log", func() bool { | |
| 94 | + | if m := displayRe.FindStringSubmatch(logs.String()); m != nil { | |
| 95 | + | display = m[1] | |
| 96 | + | return true | |
| 97 | + | } | |
| 98 | + | return false | |
| 99 | + | }) | |
| 100 | + | statePath, err := statefile.Path(runtime, "wayland-1") | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | first := startProbe(t, bin, display, "-title", "one") | |
| 106 | + | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | |
| 107 | + | ||
| 108 | + | var st statefile.State | |
| 109 | + | until(t, "one visible X11Probe window", func() bool { | |
| 110 | + | st = readState(statePath) | |
| 111 | + | return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible | |
| 112 | + | }) | |
| 113 | + | if w := st.Windows[0]; w.PID != first.cmd.Process.Pid { | |
| 114 | + | t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // The client draws green and the compositor shows it. | |
| 118 | + | id := st.Windows[0].ID | |
| 119 | + | until(t, "green window in latest.png", func() bool { | |
| 120 | + | r, ok := tileRect(readState(statePath), id) | |
| 121 | + | return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5 | |
| 122 | + | }) | |
| 123 | + | ||
| 124 | + | // Focus round trip: the compositor focused the window, the client saw it. | |
| 125 | + | until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") }) | |
| 126 | + | ||
| 127 | + | // A second probe tiles beside the first. | |
| 128 | + | second := startProbe(t, bin, display, "-title", "two") | |
| 129 | + | until(t, "two tiled windows", func() bool { | |
| 130 | + | st = readState(statePath) | |
| 131 | + | if len(st.Windows) != 2 { | |
| 132 | + | return false | |
| 133 | + | } | |
| 134 | + | for _, w := range st.Windows { | |
| 135 | + | if w.AppID != "X11Probe" || !w.Visible || w.Floating { | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | return true | |
| 140 | + | }) | |
| 141 | + | until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") }) | |
| 142 | + | ||
| 143 | + | // The close-window bind asks the focused (second) probe to close. | |
| 144 | + | if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | select { | |
| 148 | + | case err := <-second.waited: | |
| 149 | + | if err != nil { | |
| 150 | + | t.Errorf("probe two exited with %v, want 0", err) | |
| 151 | + | } | |
| 152 | + | case <-time.After(2 * time.Second): | |
| 153 | + | t.Fatal("probe two did not exit within 2s of Super+q") | |
| 154 | + | } | |
| 155 | + | until(t, "one window left", func() bool { | |
| 156 | + | st = readState(statePath) | |
| 157 | + | return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid | |
| 158 | + | }) | |
| 159 | + | select { | |
| 160 | + | case err := <-first.waited: | |
| 161 | + | t.Fatalf("probe one exited (%v) though only the focused probe was closed", err) | |
| 162 | + | default: | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // buildProbe builds testdata/x11probe. | |
| 167 | + | func buildProbe(t *testing.T) string { | |
| 168 | + | t.Helper() | |
| 169 | + | bin := filepath.Join(t.TempDir(), "x11probe") | |
| 170 | + | build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe") | |
| 171 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0") | |
| 172 | + | if out, err := build.CombinedOutput(); err != nil { | |
| 173 | + | t.Fatalf("build x11probe: %v: %s", err, out) | |
| 174 | + | } | |
| 175 | + | return bin | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // startProbe starts x11probe on display, with X11 as its only display | |
| 179 | + | // server, and stops it when the test ends. | |
| 180 | + | func startProbe(t *testing.T, bin, display string, args ...string) *probe { | |
| 181 | + | t.Helper() | |
| 182 | + | p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)} | |
| 183 | + | p.cmd = exec.Command(bin, args...) | |
| 184 | + | p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display) | |
| 185 | + | p.cmd.Stdout = p.out | |
| 186 | + | stderr := &lockedBuffer{} | |
| 187 | + | p.cmd.Stderr = stderr | |
| 188 | + | if err := p.cmd.Start(); err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | var exited sync.Once | |
| 192 | + | go func() { p.waited <- p.cmd.Wait() }() | |
| 193 | + | t.Cleanup(func() { | |
| 194 | + | exited.Do(func() { | |
| 195 | + | _ = p.cmd.Process.Kill() | |
| 196 | + | select { | |
| 197 | + | case <-p.waited: | |
| 198 | + | case <-time.After(2 * time.Second): | |
| 199 | + | } | |
| 200 | + | }) | |
| 201 | + | if s := stderr.String(); s != "" { | |
| 202 | + | t.Logf("x11probe stderr: %s", s) | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | return p | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func envWithout(keys ...string) []string { | |
| 209 | + | var env []string | |
| 210 | + | next: | |
| 211 | + | for _, kv := range os.Environ() { | |
| 212 | + | for _, k := range keys { | |
| 213 | + | if strings.HasPrefix(kv, k+"=") { | |
| 214 | + | continue next | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | env = append(env, kv) | |
| 218 | + | } | |
| 219 | + | return env | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // until polls cond every 20 ms and fails the test after xwaylandTimeout. | |
| 223 | + | func until(t *testing.T, what string, cond func() bool) { | |
| 224 | + | t.Helper() | |
| 225 | + | for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) { | |
| 226 | + | if time.Now().After(deadline) { | |
| 227 | + | t.Fatalf("timeout waiting for %s", what) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // readState returns the current state file, or an empty state while it does | |
| 233 | + | // not exist or is being replaced. | |
| 234 | + | func readState(path string) statefile.State { | |
| 235 | + | st, err := statefile.Read(path) | |
| 236 | + | if err != nil { | |
| 237 | + | return statefile.State{} | |
| 238 | + | } | |
| 239 | + | return st | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // tileRect finds the rectangle of window id in the tiled columns. | |
| 243 | + | func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) { | |
| 244 | + | for _, ws := range st.Workspaces { | |
| 245 | + | for _, c := range ws.Columns { | |
| 246 | + | for _, w := range c.Windows { | |
| 247 | + | if w.ID == id { | |
| 248 | + | return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | return image.Rectangle{}, false | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // greenShare is the fraction of pixels of r in the PNG at path that are | |
| 257 | + | // clearly green; 0 when the image cannot be read yet. | |
| 258 | + | func greenShare(path string, r image.Rectangle) float64 { | |
| 259 | + | f, err := os.Open(path) | |
| 260 | + | if err != nil { | |
| 261 | + | return 0 | |
| 262 | + | } | |
| 263 | + | defer f.Close() | |
| 264 | + | img, err := png.Decode(f) | |
| 265 | + | if err != nil { | |
| 266 | + | return 0 | |
| 267 | + | } | |
| 268 | + | r = r.Intersect(img.Bounds()) | |
| 269 | + | if r.Empty() { | |
| 270 | + | return 0 | |
| 271 | + | } | |
| 272 | + | n := 0 | |
| 273 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |
| 274 | + | for x := r.Min.X; x < r.Max.X; x++ { | |
| 275 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |
| 276 | + | if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 { | |
| 277 | + | n++ | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |
| 282 | + | } | |
| @@ -0,0 +1,282 @@ | |||||
| 1 | + | //go:build xwayland | |||
| 2 | + | ||||
| 3 | + | // End-to-end test of the built-in XWM: a real Xwayland and a real X11 client | |||
| 4 | + | // (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on | |||
| 5 | + | // PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with | |||
| 6 | + | // `make e2e-xwayland`. | |||
| 7 | + | package app | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "image" | |||
| 12 | + | "image/png" | |||
| 13 | + | "io" | |||
| 14 | + | "os" | |||
| 15 | + | "os/exec" | |||
| 16 | + | "path/filepath" | |||
| 17 | + | "regexp" | |||
| 18 | + | "strings" | |||
| 19 | + | "sync" | |||
| 20 | + | "testing" | |||
| 21 | + | "time" | |||
| 22 | + | ||||
| 23 | + | "github.com/bnema/zerowrap" | |||
| 24 | + | ||||
| 25 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 26 | + | "github.com/bnema/neferwl/internal/adapters/statefile" | |||
| 27 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | const ( | |||
| 31 | + | xwaylandTimeout = 10 * time.Second | |||
| 32 | + | xwaylandW, xwaylandH = 640, 360 | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`) | |||
| 36 | + | ||||
| 37 | + | // probe is a running x11probe. | |||
| 38 | + | type probe struct { | |||
| 39 | + | cmd *exec.Cmd | |||
| 40 | + | out *lockedBuffer | |||
| 41 | + | waited chan error | |||
| 42 | + | } | |||
| 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) { | |||
| 48 | + | if _, err := exec.LookPath("Xwayland"); err != nil { | |||
| 49 | + | t.Skip("Xwayland not on PATH") | |||
| 50 | + | } | |||
| 51 | + | // The compositor must not read or write the live session's state. | |||
| 52 | + | for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} { | |||
| 53 | + | if v, ok := os.LookupEnv(k); ok { | |||
| 54 | + | os.Unsetenv(k) | |||
| 55 | + | t.Cleanup(func() { os.Setenv(k, v) }) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | runtime := t.TempDir() | |||
| 59 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |||
| 60 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 61 | + | shots := t.TempDir() | |||
| 62 | + | bin := buildProbe(t) | |||
| 63 | + | ||||
| 64 | + | script, scriptW := io.Pipe() | |||
| 65 | + | logs := &lockedBuffer{} | |||
| 66 | + | ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs}))) | |||
| 67 | + | cfg := config.Defaults() | |||
| 68 | + | cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"} | |||
| 69 | + | done := make(chan error, 1) | |||
| 70 | + | go func() { | |||
| 71 | + | done <- Run(ctx, Options{ | |||
| 72 | + | Backend: "headless", NoTerminal: true, Config: cfg, Script: script, | |||
| 73 | + | ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}}, | |||
| 74 | + | }) | |||
| 75 | + | }() | |||
| 76 | + | t.Cleanup(func() { | |||
| 77 | + | if t.Failed() { | |||
| 78 | + | t.Logf("compositor log:\n%s", logs.String()) | |||
| 79 | + | } | |||
| 80 | + | cancel() | |||
| 81 | + | _ = scriptW.Close() | |||
| 82 | + | select { | |||
| 83 | + | case err := <-done: | |||
| 84 | + | if err != nil { | |||
| 85 | + | t.Errorf("Run: %v", err) | |||
| 86 | + | } | |||
| 87 | + | case <-time.After(5 * time.Second): | |||
| 88 | + | t.Error("Run did not stop") | |||
| 89 | + | } | |||
| 90 | + | }) | |||
| 91 | + | ||||
| 92 | + | var display string | |||
| 93 | + | until(t, "X11 display in the log", func() bool { | |||
| 94 | + | if m := displayRe.FindStringSubmatch(logs.String()); m != nil { | |||
| 95 | + | display = m[1] | |||
| 96 | + | return true | |||
| 97 | + | } | |||
| 98 | + | return false | |||
| 99 | + | }) | |||
| 100 | + | statePath, err := statefile.Path(runtime, "wayland-1") | |||
| 101 | + | if err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | first := startProbe(t, bin, display, "-title", "one") | |||
| 106 | + | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | |||
| 107 | + | ||||
| 108 | + | var st statefile.State | |||
| 109 | + | until(t, "one visible X11Probe window", func() bool { | |||
| 110 | + | st = readState(statePath) | |||
| 111 | + | return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible | |||
| 112 | + | }) | |||
| 113 | + | if w := st.Windows[0]; w.PID != first.cmd.Process.Pid { | |||
| 114 | + | t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // The client draws green and the compositor shows it. | |||
| 118 | + | id := st.Windows[0].ID | |||
| 119 | + | until(t, "green window in latest.png", func() bool { | |||
| 120 | + | r, ok := tileRect(readState(statePath), id) | |||
| 121 | + | return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5 | |||
| 122 | + | }) | |||
| 123 | + | ||||
| 124 | + | // Focus round trip: the compositor focused the window, the client saw it. | |||
| 125 | + | until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") }) | |||
| 126 | + | ||||
| 127 | + | // A second probe tiles beside the first. | |||
| 128 | + | second := startProbe(t, bin, display, "-title", "two") | |||
| 129 | + | until(t, "two tiled windows", func() bool { | |||
| 130 | + | st = readState(statePath) | |||
| 131 | + | if len(st.Windows) != 2 { | |||
| 132 | + | return false | |||
| 133 | + | } | |||
| 134 | + | for _, w := range st.Windows { | |||
| 135 | + | if w.AppID != "X11Probe" || !w.Visible || w.Floating { | |||
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | return true | |||
| 140 | + | }) | |||
| 141 | + | until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") }) | |||
| 142 | + | ||||
| 143 | + | // The close-window bind asks the focused (second) probe to close. | |||
| 144 | + | if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil { | |||
| 145 | + | t.Fatal(err) | |||
| 146 | + | } | |||
| 147 | + | select { | |||
| 148 | + | case err := <-second.waited: | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Errorf("probe two exited with %v, want 0", err) | |||
| 151 | + | } | |||
| 152 | + | case <-time.After(2 * time.Second): | |||
| 153 | + | t.Fatal("probe two did not exit within 2s of Super+q") | |||
| 154 | + | } | |||
| 155 | + | until(t, "one window left", func() bool { | |||
| 156 | + | st = readState(statePath) | |||
| 157 | + | return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid | |||
| 158 | + | }) | |||
| 159 | + | select { | |||
| 160 | + | case err := <-first.waited: | |||
| 161 | + | t.Fatalf("probe one exited (%v) though only the focused probe was closed", err) | |||
| 162 | + | default: | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // buildProbe builds testdata/x11probe. | |||
| 167 | + | func buildProbe(t *testing.T) string { | |||
| 168 | + | t.Helper() | |||
| 169 | + | bin := filepath.Join(t.TempDir(), "x11probe") | |||
| 170 | + | build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe") | |||
| 171 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 172 | + | if out, err := build.CombinedOutput(); err != nil { | |||
| 173 | + | t.Fatalf("build x11probe: %v: %s", err, out) | |||
| 174 | + | } | |||
| 175 | + | return bin | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // startProbe starts x11probe on display, with X11 as its only display | |||
| 179 | + | // server, and stops it when the test ends. | |||
| 180 | + | func startProbe(t *testing.T, bin, display string, args ...string) *probe { | |||
| 181 | + | t.Helper() | |||
| 182 | + | p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)} | |||
| 183 | + | p.cmd = exec.Command(bin, args...) | |||
| 184 | + | p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display) | |||
| 185 | + | p.cmd.Stdout = p.out | |||
| 186 | + | stderr := &lockedBuffer{} | |||
| 187 | + | p.cmd.Stderr = stderr | |||
| 188 | + | if err := p.cmd.Start(); err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | var exited sync.Once | |||
| 192 | + | go func() { p.waited <- p.cmd.Wait() }() | |||
| 193 | + | t.Cleanup(func() { | |||
| 194 | + | exited.Do(func() { | |||
| 195 | + | _ = p.cmd.Process.Kill() | |||
| 196 | + | select { | |||
| 197 | + | case <-p.waited: | |||
| 198 | + | case <-time.After(2 * time.Second): | |||
| 199 | + | } | |||
| 200 | + | }) | |||
| 201 | + | if s := stderr.String(); s != "" { | |||
| 202 | + | t.Logf("x11probe stderr: %s", s) | |||
| 203 | + | } | |||
| 204 | + | }) | |||
| 205 | + | return p | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func envWithout(keys ...string) []string { | |||
| 209 | + | var env []string | |||
| 210 | + | next: | |||
| 211 | + | for _, kv := range os.Environ() { | |||
| 212 | + | for _, k := range keys { | |||
| 213 | + | if strings.HasPrefix(kv, k+"=") { | |||
| 214 | + | continue next | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | env = append(env, kv) | |||
| 218 | + | } | |||
| 219 | + | return env | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // until polls cond every 20 ms and fails the test after xwaylandTimeout. | |||
| 223 | + | func until(t *testing.T, what string, cond func() bool) { | |||
| 224 | + | t.Helper() | |||
| 225 | + | for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) { | |||
| 226 | + | if time.Now().After(deadline) { | |||
| 227 | + | t.Fatalf("timeout waiting for %s", what) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // readState returns the current state file, or an empty state while it does | |||
| 233 | + | // not exist or is being replaced. | |||
| 234 | + | func readState(path string) statefile.State { | |||
| 235 | + | st, err := statefile.Read(path) | |||
| 236 | + | if err != nil { | |||
| 237 | + | return statefile.State{} | |||
| 238 | + | } | |||
| 239 | + | return st | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // tileRect finds the rectangle of window id in the tiled columns. | |||
| 243 | + | func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) { | |||
| 244 | + | for _, ws := range st.Workspaces { | |||
| 245 | + | for _, c := range ws.Columns { | |||
| 246 | + | for _, w := range c.Windows { | |||
| 247 | + | if w.ID == id { | |||
| 248 | + | return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | return image.Rectangle{}, false | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // greenShare is the fraction of pixels of r in the PNG at path that are | |||
| 257 | + | // clearly green; 0 when the image cannot be read yet. | |||
| 258 | + | func greenShare(path string, r image.Rectangle) float64 { | |||
| 259 | + | f, err := os.Open(path) | |||
| 260 | + | if err != nil { | |||
| 261 | + | return 0 | |||
| 262 | + | } | |||
| 263 | + | defer f.Close() | |||
| 264 | + | img, err := png.Decode(f) | |||
| 265 | + | if err != nil { | |||
| 266 | + | return 0 | |||
| 267 | + | } | |||
| 268 | + | r = r.Intersect(img.Bounds()) | |||
| 269 | + | if r.Empty() { | |||
| 270 | + | return 0 | |||
| 271 | + | } | |||
| 272 | + | n := 0 | |||
| 273 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 274 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 275 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |||
| 276 | + | if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 { | |||
| 277 | + | n++ | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |||
| 282 | + | } | |||