diff --git a/cmd/gogio/e2e_test.go b/cmd/gogio/e2e_test.go new file mode 100644 index 00000000..935b88f1 --- /dev/null +++ b/cmd/gogio/e2e_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package main_test + +import ( + "flag" + "image" + "testing" +) + +var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode") + +// TestDriver is implemented by each of the platforms we can run end-to-end +// tests on. None of its methods return any errors, as the errors are directly +// reported to testing.T via methods like Fatal. +type TestDriver interface { + // Start provides the test driver with a testing.T, as well as the path + // to the Gio app to use for the test. The app will be run with the + // given width and height. When the function returns, the gio app must + // be ready to use on the platform. + // + // The returned cleanup funcs must be run in reverse order, to mimic + // deferred funcs. + // TODO(mvdan): replace with testing.T.Cleanup once Go 1.14 is out. + Start(t *testing.T, path string, width, height int) (cleanups []func()) + + // Screenshot takes a screenshot of the Gio app on the platform. + Screenshot() image.Image +} + +func runEndToEndTest(t *testing.T, driver TestDriver) { + width, height := 800, 600 + cleanups := driver.Start(t, "testdata/red.go", width, height) + + // We expect to receive a 800x600px screenshot that's filled with + // 0xdeadbeef as the color. + img := driver.Screenshot() + size := img.Bounds().Size() + if size.X != width || size.Y != height { + t.Fatalf("expected dimensions to be %d*%d, got %d*%d", + width, height, size.X, size.Y) + } + wantColor(t, img, 5, 5, 0xdede, 0xadad, 0xbebe) + wantColor(t, img, width-5, height-5, 0xdede, 0xadad, 0xbebe) + + // Run the cleanup funcs from last to first, as if they were defers. + for i := len(cleanups) - 1; i >= 0; i-- { + cleanups[i]() + } +} + +func wantColor(t *testing.T, img image.Image, x, y int, r, g, b uint32) { + color := img.At(x, y) + r_, g_, b_, _ := color.RGBA() + if r_ != r || g_ != g || b_ != b { + t.Errorf("got 0x%04x%04x%04x at (%d,%d), want 0x%04x%04x%04x", + r_, g_, b_, x, y, r, g, b) + } +} diff --git a/cmd/gogio/js_test.go b/cmd/gogio/js_test.go index 50f18dec..fb00cea3 100644 --- a/cmd/gogio/js_test.go +++ b/cmd/gogio/js_test.go @@ -6,7 +6,6 @@ import ( "bytes" "context" "errors" - "flag" "image" "image/png" "io/ioutil" @@ -23,23 +22,28 @@ import ( _ "gioui.org/unit" // the build tool adds it to go.mod, so keep it there ) -var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode") +type JSTestDriver struct { + t *testing.T -func TestJSOnChrome(t *testing.T) { - t.Parallel() + // ctx is the chromedp context. + ctx context.Context +} + +func (d *JSTestDriver) Start(t_ *testing.T, path string, width, height int) (cleanups []func()) { + d.t = t_ // First, build the app. dir, err := ioutil.TempDir("", "gio-endtoend-js") if err != nil { - t.Fatal(err) + d.t.Fatal(err) } - defer os.RemoveAll(dir) + cleanups = append(cleanups, func() { os.RemoveAll(dir) }) // TODO(mvdan): This is inefficient, as we link the gogio tool every time. // Consider options in the future. On the plus side, this is simple. - cmd := exec.Command("go", "run", ".", "-target=js", "-o="+dir, "testdata/red.go") + cmd := exec.Command("go", "run", ".", "-target=js", "-o="+dir, path) if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("could not build app: %s:\n%s", err, out) + d.t.Fatalf("could not build app: %s:\n%s", err, out) } // Second, start Chrome. @@ -64,20 +68,21 @@ func TestJSOnChrome(t *testing.T) { ) actx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) - defer cancel() + cleanups = append(cleanups, cancel) ctx, cancel := chromedp.NewContext(actx, // Send all logf/errf calls to t.Logf - chromedp.WithLogf(t.Logf), + chromedp.WithLogf(d.t.Logf), ) - defer cancel() + cleanups = append(cleanups, cancel) + d.ctx = ctx if err := chromedp.Run(ctx); err != nil { if errors.Is(err, exec.ErrNotFound) { - t.Skipf("test requires Chrome to be installed: %v", err) + d.t.Skipf("test requires Chrome to be installed: %v", err) return } - t.Fatal(err) + d.t.Fatal(err) } chromedp.ListenTarget(ctx, func(ev interface{}) { switch ev := ev.(type) { @@ -91,7 +96,7 @@ func TestJSOnChrome(t *testing.T) { } args.Write(arg.Value) } - t.Logf("console %s: %s", ev.Type, args.String()) + d.t.Logf("console %s: %s", ev.Type, args.String()) } } }) @@ -99,46 +104,40 @@ func TestJSOnChrome(t *testing.T) { // Third, serve the app folder, set the browser tab dimensions, and // navigate to the folder. ts := httptest.NewServer(http.FileServer(http.Dir(dir))) - defer ts.Close() + cleanups = append(cleanups, ts.Close) if err := chromedp.Run(ctx, - // A small window with 2x HiDPI. - chromedp.EmulateViewport(300, 300, chromedp.EmulateScale(2.0)), + chromedp.EmulateViewport(int64(width), int64(height)), chromedp.Navigate(ts.URL), ); err != nil { - t.Fatal(err) + d.t.Fatal(err) } - // Finally, run the test. - - // 1: Once the canvas is ready, grab a screenshot to check that the - // entirety of the viewport is red, as per the background color. - var buf []byte if err := chromedp.Run(ctx, chromedp.WaitReady("canvas", chromedp.ByQuery), + ); err != nil { + d.t.Fatal(err) + } + + return cleanups +} + +func (d *JSTestDriver) Screenshot() image.Image { + var buf []byte + if err := chromedp.Run(d.ctx, chromedp.CaptureScreenshot(&buf), ); err != nil { - t.Fatal(err) + d.t.Fatal(err) } img, err := png.Decode(bytes.NewReader(buf)) if err != nil { - t.Fatal(err) + d.t.Fatal(err) } - size := img.Bounds().Size() - wantSize := 600 // 300px at 2.0 scaling factor - if size.X != wantSize || size.Y != wantSize { - t.Fatalf("expected dimensions to be %d*%d, got %d*%d", - wantSize, wantSize, size.X, size.Y) - } - wantColor(t, img, 5, 5, 0xdede, 0xadad, 0xbebe) - wantColor(t, img, 595, 595, 0xdede, 0xadad, 0xbebe) + return img } -func wantColor(t *testing.T, img image.Image, x, y int, r, g, b uint32) { - color := img.At(x, y) - r_, g_, b_, _ := color.RGBA() - if r_ != r || g_ != g || b_ != b { - t.Errorf("got 0x%04x%04x%04x at (%d,%d), want 0x%04x%04x%04x", - r_, g_, b_, x, y, r, g, b) - } +func TestJS(t *testing.T) { + t.Parallel() + + runEndToEndTest(t, &JSTestDriver{}) } diff --git a/cmd/gogio/x11_test.go b/cmd/gogio/x11_test.go index 561d1e4c..3c17ffd8 100644 --- a/cmd/gogio/x11_test.go +++ b/cmd/gogio/x11_test.go @@ -10,6 +10,7 @@ import ( "bytes" "context" "fmt" + "image" "io" "io/ioutil" "math/rand" @@ -26,8 +27,15 @@ import ( "github.com/BurntSushi/xgbutil/xgraphics" ) -func TestX11(t *testing.T) { - t.Parallel() +type X11TestDriver struct { + t *testing.T + + // conn holds the connection to X. + conn *xgbutil.XUtil +} + +func (d *X11TestDriver) Start(t_ *testing.T, path string, width, height int) (cleanups []func()) { + d.t = t_ // Pick a random display number between 1 and 100,000. Most machines // will only be using :0, so there's only a 0.001% chance of two @@ -39,31 +47,31 @@ func TestX11(t *testing.T) { xflags := []string{"-wr"} if *headless { xprog = "Xvfb" // virtual X server - xflags = append(xflags, "-screen", "0", "600x600x24") + xflags = append(xflags, "-screen", "0", fmt.Sprintf("%dx%dx24", width, height)) } else { xprog = "Xephyr" // nested X server as a window - xflags = append(xflags, "-screen", "600x600") + xflags = append(xflags, "-screen", fmt.Sprintf("%dx%d", width, height)) } xflags = append(xflags, display) if _, err := exec.LookPath(xprog); err != nil { - t.Skipf("%s needed to run with -headless=%t", xprog, *headless) + d.t.Skipf("%s needed to run with -headless=%t", xprog, *headless) } // First, build the app. dir, err := ioutil.TempDir("", "gio-endtoend-x11") if err != nil { - t.Fatal(err) + d.t.Fatal(err) } - defer os.RemoveAll(dir) + cleanups = append(cleanups, func() { os.RemoveAll(dir) }) bin := filepath.Join(dir, "red") - cmd := exec.Command("go", "build", "-o="+bin, "testdata/red.go") + cmd := exec.Command("go", "build", "-o="+bin, path) if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("could not build app: %s:\n%s", err, out) + d.t.Fatalf("could not build app: %s:\n%s", err, out) } var wg sync.WaitGroup - defer wg.Wait() + cleanups = append(cleanups, wg.Wait) // First, start the X server. { @@ -73,16 +81,16 @@ func TestX11(t *testing.T) { cmd.Stdout = combined cmd.Stderr = combined if err := cmd.Start(); err != nil { - t.Fatal(err) + d.t.Fatal(err) } - defer cancel() - defer func() { + 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. cmd.Process.Signal(os.Interrupt) time.Sleep(10 * time.Millisecond) - }() + }) // Wait for up to 1s (100 * 10ms) for the X server to be ready. for i := 0; ; i++ { @@ -95,7 +103,7 @@ func TestX11(t *testing.T) { break } if i >= 100 { - t.Fatalf("timed out waiting for %s", socket) + d.t.Fatalf("timed out waiting for %s", socket) } } @@ -104,7 +112,7 @@ func TestX11(t *testing.T) { if err := cmd.Wait(); err != nil && ctx.Err() == nil { // Print all output and error. io.Copy(os.Stdout, combined) - t.Error(err) + d.t.Error(err) } wg.Done() }() @@ -119,58 +127,58 @@ func TestX11(t *testing.T) { cmd.Stdout = out cmd.Stderr = out if err := cmd.Start(); err != nil { - t.Fatal(err) + d.t.Fatal(err) } - defer cancel() + cleanups = append(cleanups, cancel) wg.Add(1) go func() { if err := cmd.Wait(); err != nil && ctx.Err() == nil { // Print all output and error. io.Copy(os.Stdout, out) - t.Error(err) + d.t.Error(err) } wg.Done() }() } - // Finally, run our tests. A connection to the X server is used to - // interact with it. - { - xgb.Logger.SetOutput(testLogWriter{t}) - xgbutil.Logger.SetOutput(testLogWriter{t}) - xu, err := xgbutil.NewConnDisplay(display) - if err != nil { - t.Fatal(err) - } - defer func() { - xu.Conn().Close() - // TODO(mvdan): Figure out a way to remove this sleep - // without introducing a panic. The xgb code will - // encounter a panic if the Xorg server exits before xgb - // has shut down fully. - // See: https://github.com/BurntSushi/xgb/pull/44 - time.Sleep(10 * time.Millisecond) - }() - - // Wait for the gio app to render. - // TODO(mvdan): do this properly, e.g. via waiting for log lines - // from the gio program. - time.Sleep(200 * time.Millisecond) - - img, err := xgraphics.NewDrawable(xu, xproto.Drawable(xu.RootWin())) - if err != nil { - t.Fatal(err) - } - - size := img.Bounds().Size() - wantSize := 600 // 300px at 2.0 scaling factor - if size.X != wantSize || size.Y != wantSize { - t.Fatalf("expected dimensions to be %d*%d, got %d*%d", - wantSize, wantSize, size.X, size.Y) - } - wantColor(t, img, 5, 5, 0xdede, 0xadad, 0xbebe) - wantColor(t, img, 595, 595, 0xdede, 0xadad, 0xbebe) + // Finally, connect to the X server. + xgb.Logger.SetOutput(testLogWriter{d.t}) + xgbutil.Logger.SetOutput(testLogWriter{d.t}) + conn, err := xgbutil.NewConnDisplay(display) + if err != nil { + d.t.Fatal(err) } + d.conn = conn + cleanups = append(cleanups, func() { + conn.Conn().Close() + // TODO(mvdan): Figure out a way to remove this sleep + // without introducing a panic. The xgb code will + // encounter a panic if the Xorg server exits before xgb + // has shut down fully. + // See: https://github.com/BurntSushi/xgb/pull/44 + time.Sleep(10 * time.Millisecond) + }) + + // Wait for the gio app to render. + // TODO(mvdan): do this properly, e.g. via waiting for log lines + // from the gio program. + time.Sleep(200 * time.Millisecond) + + return cleanups +} + +func (d *X11TestDriver) Screenshot() image.Image { + img, err := xgraphics.NewDrawable(d.conn, xproto.Drawable(d.conn.RootWin())) + if err != nil { + d.t.Fatal(err) + } + return img +} + +func TestX11(t *testing.T) { + t.Parallel() + + runEndToEndTest(t, &X11TestDriver{}) } // testLogWriter is a bit of a hack to redirect libraries that use a *log.Logger