diff --git a/cmd/gogio/android_test.go b/cmd/gogio/android_test.go new file mode 100644 index 00000000..65713923 --- /dev/null +++ b/cmd/gogio/android_test.go @@ -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 a/cmd/gogio/e2e_test.go b/cmd/gogio/e2e_test.go index 80b91355..b7f6a9ef 100644 --- a/cmd/gogio/e2e_test.go +++ b/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