~eliasnaur/gio-patches

cmd/gogio: add the first Android end-to-end test v1 PROPOSED

Daniel Martí: 1
 cmd/gogio: add the first Android end-to-end test

 2 files changed, 183 insertions(+), 15 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~eliasnaur/gio-patches/patches/9667/mbox | git am -3
Learn more about email & git

[PATCH] cmd/gogio: add the first Android end-to-end test Export this patch

It passes the whole e2e test flow on my real device, a OnePlus 5 running
LineageOS 16.0 (Android 9).

I was also successful at running it against an x86-64 Android 8.0
emulator, but I'm not including any of that just yet. A patch later this
week will include a piece of code to set up and start an emulator, which
CI can then use to run the test.

Also stop requiring the screen dimensions to be enforced when running in
non-headless mode. An Android emulator can run at an arbitrary
resolution, and even in headless mode, but a real Android device will
have its own predefined resolution. Forcing the test user to set the
-headless=false flag to not get annoying "unexpected dimensions" errors
would be annoying.

That check doesn't really mean much, as our test app doesn't care about
the screen resolution. And we were only doing the check sometimes. Drop
it entirely, making the resolution parameters merely a hint so that we
can keep the drivers a bit more consistent.

Signed-off-by: Daniel Martí <mvdan@mvdan.cc>
---
 cmd/gogio/android_test.go | 176 ++++++++++++++++++++++++++++++++++++++
 cmd/gogio/e2e_test.go     |  22 ++---
 2 files changed, 183 insertions(+), 15 deletions(-)
 create mode 100644 cmd/gogio/android_test.go

diff --git cmd/gogio/android_test.go cmd/gogio/android_test.go
new file mode 100644
index 0000000..6571392
--- /dev/null
@@ -0,0 +1,176 @@
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"image"
	"image/png"
	"io/ioutil"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
	"time"
)

type AndroidTestDriver struct {
	t *testing.T

	frameNotifs chan bool

	sdkDir  string
	adbPath string
}

var rxAdbDevice = regexp.MustCompile(`(.*)\s+device$`)

func (d *AndroidTestDriver) Start(t_ *testing.T, path string, width, height int) {
	d.frameNotifs = make(chan bool, 1)
	d.t = t_

	d.sdkDir = os.Getenv("ANDROID_HOME")
	if d.sdkDir == "" {
		d.t.Skipf("Android SDK is required; set $ANDROID_HOME")
	}
	d.adbPath = filepath.Join(d.sdkDir, "platform-tools", "adb")

	devOut := bytes.TrimSpace(d.adb("devices"))
	devices := rxAdbDevice.FindAllSubmatch(devOut, -1)
	switch len(devices) {
	case 0:
		d.t.Skipf("no Android devices attached via adb; skipping")
	case 1:
	default:
		d.t.Skipf("multiple Android devices attached via adb; skipping")
	}

	// If the device is attached but asleep, it's probably just charging.
	// Don't use it; the screen needs to be on and unlocked for the test to
	// work.
	if !bytes.Contains(
		d.adb("shell", "dumpsys", "power"),
		[]byte(" mWakefulness=Awake"),
	) {
		d.t.Skipf("Android device isn't awake; skipping")
	}

	// First, build the app.
	dir, err := ioutil.TempDir("", "gio-endtoend-android")
	if err != nil {
		d.t.Fatal(err)
	}
	d.t.Cleanup(func() { os.RemoveAll(dir) })
	apk := filepath.Join(dir, "e2e.apk")

	// 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=android", "-appid="+appid, "-o="+apk, path)
	if out, err := cmd.CombinedOutput(); err != nil {
		d.t.Fatalf("could not build app: %s:\n%s", err, out)
	}

	// Make sure the app isn't installed already, and try to uninstall it
	// when we finish. Previous failed test runs might have left the app.
	d.tryUninstall()
	d.adb("install", apk)
	d.t.Cleanup(d.tryUninstall)

	// Force our e2e app to be fullscreen, so that the android system bar at
	// the top doesn't mess with our screenshots.
	// TODO(mvdan): is there a way to do this via gio, so that we don't need
	// to set up a global Android setting via the shell?
	d.adb("shell", "settings", "put", "global", "policy_control", "immersive.full="+appid)

	// Make sure the app isn't already running.
	d.adb("shell", "pm", "clear", appid)

	// Start listening for log messages.
	{
		ctx, cancel := context.WithCancel(context.Background())
		cmd := exec.CommandContext(ctx, d.adbPath,
			"logcat",
			"-s",    // suppress other logs
			"-T1",   // don't show prevoius log messages
			"gio:*", // show all logs from gio
		)
		logcat, err := cmd.StdoutPipe()
		if err != nil {
			d.t.Fatal(err)
		}
		cmd.Stderr = os.Stderr
		if err := cmd.Start(); err != nil {
			d.t.Fatal(err)
		}
		d.t.Cleanup(cancel)
		go func() {
			scanner := bufio.NewScanner(logcat)
			for scanner.Scan() {
				line := scanner.Text()
				if strings.HasSuffix(line, ": frame ready") {
					d.frameNotifs <- true
				}
			}
		}()
	}

	// Start the app.
	d.adb("shell", "monkey", "-p", appid, "1")

	// Unfortunately, it seems like waiting for the initial frame isn't
	// enough. Most Android versions have animations when opening apps that
	// run for hundreds of milliseconds, so that's probably the reason.
	// TODO(mvdan): any way to wait for the screen to be ready without a
	// static sleep?
	time.Sleep(500 * time.Millisecond)

	// Wait for the gio app to render.
	waitForFrame(d.t, d.frameNotifs)
}

func (d *AndroidTestDriver) Screenshot() image.Image {
	out := d.adb("shell", "screencap", "-p")
	img, err := png.Decode(bytes.NewReader(out))
	if err != nil {
		d.t.Fatal(err)
	}
	return img
}

func (d *AndroidTestDriver) tryUninstall() {
	cmd := exec.Command(d.adbPath, "shell", "pm", "uninstall", appid)
	out, err := cmd.CombinedOutput()
	if err != nil {
		if bytes.Contains(out, []byte("Unknown package")) {
			// The package is not installed. Don't log anything.
			return
		}
		d.t.Logf("could not uninstall: %v\n%s", err, out)
	}
}

func (d *AndroidTestDriver) adb(args ...interface{}) []byte {
	strs := []string{}
	for _, arg := range args {
		strs = append(strs, fmt.Sprint(arg))
	}
	cmd := exec.Command(d.adbPath, strs...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		d.t.Errorf("%s", out)
		d.t.Fatal(err)
	}
	return out
}

func (d *AndroidTestDriver) Click(x, y int) {
	d.adb("shell", "input", "tap", x, y)

	// Wait for the gio app to render after this click.
	waitForFrame(d.t, d.frameNotifs)
}
diff --git cmd/gogio/e2e_test.go cmd/gogio/e2e_test.go
index 80b9135..b7f6a9e 100644
--- cmd/gogio/e2e_test.go
@@ -14,14 +14,16 @@ var raceEnabled = false

var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode")

const appid = "localhost.gogio.endtoend"

// 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, and the platform's background should be
	// white.
	// to the Gio app to use for the test. The driver should attempt to run
	// the app with the given width and height, and the platform's
	// background should be white.
	//
	// When the function returns, the gio app must be ready to use on the
	// platform, with its initial frame fully drawn.
@@ -51,6 +53,7 @@ func TestEndToEnd(t *testing.T) {
		{"X11", &X11TestDriver{}},
		{"Wayland", &WaylandTestDriver{}},
		{"JS", &JSTestDriver{}},
		{"Android", &AndroidTestDriver{}},
	}

	for _, subtest := range subtests {
@@ -73,18 +76,7 @@ func runEndToEndTest(t *testing.T, driver TestDriver) {
	wantColors := func(topLeft, topRight, botLeft, botRight color.RGBA) {
		t.Helper()
		img := driver.Screenshot()
		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_)
			}
		}
		size = img.Bounds().Size()
		{
			minX, minY := 5, 5
			maxX, maxY := (size.X/2)-5, (size.Y/2)-5
-- 
2.25.0
Thank you very much. I'm sorry e2e testing on Android is so
fiddly.
View this thread in the archives