diff --git a/cmd/gogio/e2e_test.go b/cmd/gogio/e2e_test.go index e2ee5646..de7017f3 100644 --- a/cmd/gogio/e2e_test.go +++ b/cmd/gogio/e2e_test.go @@ -38,8 +38,8 @@ type TestDriver interface { } func runEndToEndTest(t *testing.T, driver TestDriver) { - width, height := 800, 600 - cleanups := driver.Start(t, "testdata/red.go", width, height) + size := image.Point{X: 800, Y: 600} + cleanups := driver.Start(t, "testdata/red.go", size.X, size.Y) for _, cleanup := range cleanups { defer cleanup() } @@ -50,39 +50,45 @@ func runEndToEndTest(t *testing.T, driver TestDriver) { wantColors := func(topLeft, topRight, botLeft, botRight color.RGBA) { t.Helper() img := driver.Screenshot() - size := img.Bounds().Size() - // We expect to receive a width*height screenshot. - if size.X != width || size.Y != height { - t.Fatalf("expected dimensions to be %d*%d, got %d*%d", - width, height, size.X, size.Y) + size_ := img.Bounds().Size() + if size_ != size { + if !*headless { + // Some non-headless drivers, like Sway, may get + // their window resized by the host window manager. + // Run the rest of the test with the new size. + size = size_ + } else { + t.Fatalf("expected dimensions to be %v, got %v", + size, size_) + } } { minX, minY := 5, 5 - maxX, maxY := (width/2)-5, (height/2)-5 + maxX, maxY := (size.X/2)-5, (size.Y/2)-5 wantColor(t, img, minX, minY, topLeft) wantColor(t, img, maxX, minY, topLeft) wantColor(t, img, minX, maxY, topLeft) wantColor(t, img, maxX, maxY, topLeft) } { - minX, minY := (width/2)+5, 5 - maxX, maxY := width-5, (height/2)-5 + minX, minY := (size.X/2)+5, 5 + maxX, maxY := size.X-5, (size.Y/2)-5 wantColor(t, img, minX, minY, topRight) wantColor(t, img, maxX, minY, topRight) wantColor(t, img, minX, maxY, topRight) wantColor(t, img, maxX, maxY, topRight) } { - minX, minY := 5, (height/2)+5 - maxX, maxY := (width/2)-5, height-5 + minX, minY := 5, (size.Y/2)+5 + maxX, maxY := (size.X/2)-5, size.Y-5 wantColor(t, img, minX, minY, botLeft) wantColor(t, img, maxX, minY, botLeft) wantColor(t, img, minX, maxY, botLeft) wantColor(t, img, maxX, maxY, botLeft) } { - minX, minY := (width/2)+5, (height/2)+5 - maxX, maxY := width-5, height-5 + minX, minY := (size.X/2)+5, (size.Y/2)+5 + maxX, maxY := size.X-5, size.Y-5 wantColor(t, img, minX, minY, botRight) wantColor(t, img, maxX, minY, botRight) wantColor(t, img, minX, maxY, botRight) @@ -100,8 +106,14 @@ func runEndToEndTest(t *testing.T, driver TestDriver) { wantColors(beef, white, black, gray) // Click the first and last sections to turn them red. - driver.Click(1*(width/4), 1*(height/4)) - driver.Click(3*(width/4), 3*(height/4)) + // TODO(mvdan): implement this properly in the Wayland driver; swaymsg + // almost works to automate clicks, but the button presses end up in the + // wrong coordinates. + if _, ok := driver.(*WaylandTestDriver); ok { + return + } + driver.Click(1*(size.X/4), 1*(size.Y/4)) + driver.Click(3*(size.X/4), 3*(size.Y/4)) wantColors(red, white, black, red) } diff --git a/cmd/gogio/wayland_test.go b/cmd/gogio/wayland_test.go new file mode 100644 index 00000000..bd93aa77 --- /dev/null +++ b/cmd/gogio/wayland_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package main_test + +import ( + "bufio" + "bytes" + "context" + "fmt" + "image" + "image/png" + "io" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "text/template" + "time" +) + +type WaylandTestDriver struct { + t *testing.T + + frameNotifs chan bool + + runtimeDir string + socket string + display string +} + +// No bars or anything fancy. Just a white background with our dimensions. +var tmplSwayConfig = template.Must(template.New("").Parse(` +output * bg #FFFFFF solid_color +output * mode {{.Width}}x{{.Height}} +default_border none +`)) + +var rxSwayReady = regexp.MustCompile(`Running compositor on wayland display '(.*)'`) + +func (d *WaylandTestDriver) Start(t_ *testing.T, path string, width, height int) (cleanups []func()) { + d.frameNotifs = make(chan bool, 1) + d.t = t_ + + // We want os.Environ, so that it can e.g. find $DISPLAY to run within + // X11. wlroots env vars are documented at: + // https://github.com/swaywm/wlroots/blob/master/docs/env_vars.md + env := os.Environ() + if *headless { + env = append(env, "WLR_BACKENDS=headless") + } + + for _, prog := range []string{ + "sway", // to run a wayland compositor + "grim", // to take screenshots + "swaymsg", // to send input + } { + if _, err := exec.LookPath(prog); err != nil { + d.t.Skipf("%s needed to run", prog) + } + } + + // First, build the app. + dir, err := ioutil.TempDir("", "gio-endtoend-wayland") + if err != nil { + d.t.Fatal(err) + } + cleanups = append(cleanups, func() { os.RemoveAll(dir) }) + + bin := filepath.Join(dir, "red") + cmd := exec.Command("go", "build", "-tags", "nox11", "-o="+bin, path) + if out, err := cmd.CombinedOutput(); err != nil { + d.t.Fatalf("could not build app: %s:\n%s", err, out) + } + + conf := filepath.Join(dir, "config") + f, err := os.Create(conf) + if err != nil { + d.t.Fatal(err) + } + defer f.Close() + if err := tmplSwayConfig.Execute(f, struct{ Width, Height int }{ + width, height, + }); err != nil { + d.t.Fatal(err) + } + + d.socket = filepath.Join(dir, "socket") + env = append(env, "SWAYSOCK="+d.socket) + d.runtimeDir = dir + env = append(env, "XDG_RUNTIME_DIR="+d.runtimeDir) + + var wg sync.WaitGroup + cleanups = append(cleanups, wg.Wait) + + // First, start sway. + { + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, "sway", "--config", conf, "--verbose") + cmd.Env = env + stderr, err := cmd.StderrPipe() + if err != nil { + log.Fatal(err) + } + if err := cmd.Start(); err != nil { + d.t.Fatal(err) + } + cleanups = append(cleanups, cancel) + cleanups = append(cleanups, func() { + // Give it a chance to exit gracefully, cleaning up + // after itself. After 10ms, the deferred cancel above + // will signal an os.Kill. + cmd.Process.Signal(os.Interrupt) + time.Sleep(10 * time.Millisecond) + }) + + // Wait for sway to be ready. We probably don't need a deadline + // here. + br := bufio.NewReader(stderr) + for { + line, err := br.ReadString('\n') + if err != nil { + d.t.Fatal(err) + } + if m := rxSwayReady.FindStringSubmatch(line); m != nil { + d.display = m[1] + break + } + } + + wg.Add(1) + go func() { + if err := cmd.Wait(); err != nil && ctx.Err() == nil && !strings.Contains(err.Error(), "interrupt") { + // Don't print all stderr, since we use --verbose. + // TODO(mvdan): if it's useful, probably filter + // errors and show them. + d.t.Error(err) + } + wg.Done() + }() + } + + // Then, start our program on the sway compositor above. + { + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, bin) + cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display} + stdout, err := cmd.StdoutPipe() + if err != nil { + d.t.Fatal(err) + } + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + if err := cmd.Start(); err != nil { + d.t.Fatal(err) + } + cleanups = append(cleanups, cancel) + wg.Add(1) + go func() { + if err := cmd.Wait(); err != nil && ctx.Err() == nil { + // Print stderr and error. + io.Copy(os.Stdout, stderr) + d.t.Error(err) + } + wg.Done() + }() + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line == "frame ready" { + d.frameNotifs <- true + } + } + }() + } + + // Wait for the gio app to render. + <-d.frameNotifs + + return cleanups +} + +func (d *WaylandTestDriver) Screenshot() image.Image { + cmd := exec.Command("grim", "/dev/stdout") + cmd.Env = []string{"XDG_RUNTIME_DIR=" + d.runtimeDir, "WAYLAND_DISPLAY=" + d.display} + out, err := cmd.CombinedOutput() + if err != nil { + d.t.Errorf("%s", out) + d.t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + d.t.Fatal(err) + } + return img +} + +func (d *WaylandTestDriver) swaymsg(args ...interface{}) { + strs := []string{ + "--socket", d.socket, + } + for _, arg := range args { + strs = append(strs, fmt.Sprint(arg)) + } + cmd := exec.Command("swaymsg", strs...) + if out, err := cmd.CombinedOutput(); err != nil { + d.t.Errorf("%s", out) + d.t.Fatal(err) + } else { + d.t.Logf("%v", args) + d.t.Logf("%s", out) + } +} + +func (d *WaylandTestDriver) Click(x, y int) { + d.swaymsg("-t", "get_seats") + d.swaymsg("seat", "-", "cursor", "set", x, y) + d.swaymsg("seat", "-", "cursor", "press", "button1") + d.swaymsg("seat", "-", "cursor", "release", "button1") + + // Wait for the gio app to render after this click. + <-d.frameNotifs +} + +func TestWayland(t *testing.T) { + t.Parallel() + + runEndToEndTest(t, &WaylandTestDriver{}) +} diff --git a/cmd/gogio/x11_test.go b/cmd/gogio/x11_test.go index 5b772312..8df1cf6a 100644 --- a/cmd/gogio/x11_test.go +++ b/cmd/gogio/x11_test.go @@ -1,9 +1,5 @@ // SPDX-License-Identifier: Unlicense OR MIT -// TODO(mvdan): come up with an end-to-end platform interface, including methods -// like "take screenshot" or "close app", so that we can run the same tests on -// all supported platforms without writing them many times. - package main_test import ( @@ -93,9 +89,9 @@ func (d *X11TestDriver) Start(t_ *testing.T, path string, width, height int) (cl } cleanups = append(cleanups, cancel) cleanups = append(cleanups, func() { - // Give Xserver a chance to exit gracefully, cleaning up - // after itself in /tmp. After 10ms, the deferred cancel - // above will signal an os.Kill. + // Give it a chance to exit gracefully, cleaning up + // after itself. After 10ms, the deferred cancel above + // will signal an os.Kill. cmd.Process.Signal(os.Interrupt) time.Sleep(10 * time.Millisecond) }) @@ -200,6 +196,7 @@ func (d *X11TestDriver) Click(x, y int) { d.xdotool("mousemove", x, y) d.xdotool("click", "1") + // Wait for the gio app to render after this click. <-d.frameNotifs } @@ -208,18 +205,3 @@ func TestX11(t *testing.T) { runEndToEndTest(t, &X11TestDriver{}) } - -// testLogWriter is a bit of a hack to redirect libraries that use a *log.Logger -// variable to instead send their logs to t.Logf. -// -// Since *log.Logger isn't an interface and can only take an io.Writer, all we -// can do is implement an io.Writer that sends its output to t.Logf. We end up -// with duplicate log prefixes, but that doesn't seem so bad. -type testLogWriter struct { - t *testing.T -} - -func (w testLogWriter) Write(p []byte) (n int, err error) { - w.t.Logf("%s", p) - return len(p), nil -}