From b3d4da62296fa80564c76befc8cff32cad0755d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mart=C3=AD?= Date: Wed, 30 Oct 2019 21:52:46 +0000 Subject: [PATCH] cmd/gogio: start using a TestDriver e2e interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now we implement the "red background" end-to-end test exactly once. While at it, start using a 800x600 window size, which is a bit more realistic than 600x600, and will catch if we got either dimension wrong. The interface only has two methods for now, but it will be expanded in the future to also support input such as clicks. Keeping state in the test driver, such as a context or a connection, is a bit awkward but necessary so that we don't have to repeat arguments over and over. The same applies to testing.T. Signed-off-by: Daniel Martí --- cmd/gogio/e2e_test.go | 59 +++++++++++++++++++++ cmd/gogio/js_test.go | 79 ++++++++++++++------------- cmd/gogio/x11_test.go | 120 ++++++++++++++++++++++-------------------- 3 files changed, 162 insertions(+), 96 deletions(-) create mode 100644 cmd/gogio/e2e_test.go 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