github.com/cybriq/giocore@v0.0.7-0.20210703034601-cfb9cb5f3900/cmd/gogio/e2e_test.go (about) 1 // SPDX-License-Identifier: Unlicense OR MIT 2 3 package main_test 4 5 import ( 6 "bufio" 7 "errors" 8 "flag" 9 "fmt" 10 "image" 11 "image/color" 12 "io" 13 "io/ioutil" 14 "os" 15 "os/exec" 16 "strings" 17 "testing" 18 "time" 19 ) 20 21 var raceEnabled = false 22 23 var headless = flag.Bool("headless", true, "run end-to-end tests in headless mode") 24 25 const appid = "localhost.gogio.endtoend" 26 27 // TestDriver is implemented by each of the platforms we can run end-to-end 28 // tests on. None of its methods return any errors, as the errors are directly 29 // reported to testing.T via methods like Fatal. 30 type TestDriver interface { 31 initBase(t *testing.T, width, height int) 32 33 // Start opens the Gio app found at path. The driver should attempt to 34 // run the app with the base driver's width and height, and the 35 // platform's background should be white. 36 // 37 // When the function returns, the gio app must be ready to use on the 38 // platform, with its initial frame fully drawn. 39 Start(path string) 40 41 // Screenshot takes a screenshot of the Gio app on the platform. 42 Screenshot() image.Image 43 44 // Click performs a pointer click at the specified coordinates, 45 // including both press and release. It returns when the next frame is 46 // fully drawn. 47 Click(x, y int) 48 } 49 50 type driverBase struct { 51 *testing.T 52 53 width, height int 54 55 output io.Reader 56 frameNotifs chan bool 57 } 58 59 func (d *driverBase) initBase(t *testing.T, width, height int) { 60 d.T = t 61 d.width, d.height = width, height 62 } 63 64 // func TestEndToEnd(t *testing.T) { 65 // if testing.Short() { 66 // t.Skipf("end-to-end tests tend to be slow") 67 // } 68 // 69 // t.Parallel() 70 // 71 // const ( 72 // testdataWithGoImportPkgPath = "github.com/cybriq/giocore/cmd/gogio/testdata" 73 // testdataWithRelativePkgPath = "testdata/testdata.go_" 74 // ) 75 // // Keep this list local, to not reuse TestDriver objects. 76 // subtests := []struct { 77 // name string 78 // driver TestDriver 79 // pkgPath string 80 // }{ 81 // {"X11 using go import path", &X11TestDriver{}, testdataWithGoImportPkgPath}, 82 // {"X11", &X11TestDriver{}, testdataWithRelativePkgPath}, 83 // {"Wayland", &WaylandTestDriver{}, testdataWithRelativePkgPath}, 84 // {"JS", &JSTestDriver{}, testdataWithRelativePkgPath}, 85 // {"Android", &AndroidTestDriver{}, testdataWithRelativePkgPath}, 86 // {"Windows", &WineTestDriver{}, testdataWithRelativePkgPath}, 87 // } 88 // 89 // for _, subtest := range subtests { 90 // t.Run(subtest.name, func(t *testing.T) { 91 // subtest := subtest // copy the changing loop variable 92 // t.Parallel() 93 // runEndToEndTest(t, subtest.driver, subtest.pkgPath) 94 // }) 95 // } 96 // } 97 98 func runEndToEndTest(t *testing.T, driver TestDriver, pkgPath string) { 99 size := image.Point{X: 800, Y: 600} 100 driver.initBase(t, size.X, size.Y) 101 102 t.Log("starting driver and gio app") 103 driver.Start(pkgPath) 104 105 beef := color.NRGBA{R: 0xde, G: 0xad, B: 0xbe, A: 0xff} 106 white := color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} 107 black := color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xff} 108 gray := color.NRGBA{R: 0xbb, G: 0xbb, B: 0xbb, A: 0xff} 109 red := color.NRGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff} 110 111 // These are the four colors at the beginning. 112 t.Log("taking initial screenshot") 113 withRetries(t, 4*time.Second, func() error { 114 img := driver.Screenshot() 115 size = img.Bounds().Size() // override the default size 116 return checkImageCorners(img, beef, white, black, gray) 117 }) 118 119 // TODO(mvdan): implement this properly in the Wayland driver; swaymsg 120 // almost works to automate clicks, but the button presses end up in the 121 // wrong coordinates. 122 if _, ok := driver.(*WaylandTestDriver); ok { 123 return 124 } 125 126 // Click the first and last sections to turn them red. 127 t.Log("clicking twice and taking another screenshot") 128 driver.Click(1*(size.X/4), 1*(size.Y/4)) 129 driver.Click(3*(size.X/4), 3*(size.Y/4)) 130 withRetries(t, 4*time.Second, func() error { 131 img := driver.Screenshot() 132 return checkImageCorners(img, red, white, black, red) 133 }) 134 } 135 136 // withRetries keeps retrying fn until it succeeds, or until the timeout is hit. 137 // It uses a rudimentary kind of backoff, which starts with 100ms delays. As 138 // such, timeout should generally be in the order of seconds. 139 func withRetries(t *testing.T, timeout time.Duration, fn func() error) { 140 t.Helper() 141 142 timeoutTimer := time.NewTimer(timeout) 143 defer timeoutTimer.Stop() 144 backoff := 100 * time.Millisecond 145 146 tries := 0 147 var lastErr error 148 for { 149 if lastErr = fn(); lastErr == nil { 150 return 151 } 152 tries++ 153 t.Logf("retrying after %s", backoff) 154 155 // Use a timer instead of a sleep, so that the timeout can stop 156 // the backoff early. Don't reuse this timer, since we're not in 157 // a hot loop, and we don't want tricky code. 158 backoffTimer := time.NewTimer(backoff) 159 defer backoffTimer.Stop() 160 161 select { 162 case <-timeoutTimer.C: 163 t.Errorf("last error: %v", lastErr) 164 t.Fatalf("hit timeout of %s after %d tries", timeout, tries) 165 case <-backoffTimer.C: 166 } 167 168 // Keep doubling it until a maximum. With the start at 100ms, 169 // we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever. 170 backoff *= 2 171 if max := 2 * time.Second; backoff > max { 172 backoff = max 173 } 174 } 175 } 176 177 type colorMismatch struct { 178 x, y int 179 wantRGB, gotRGB [3]uint32 180 } 181 182 func (m colorMismatch) String() string { 183 return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x", 184 m.x, m.y, 185 m.gotRGB[0], m.gotRGB[1], m.gotRGB[2], 186 m.wantRGB[0], m.wantRGB[1], m.wantRGB[2], 187 ) 188 } 189 190 func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.Color) error { 191 // The colors are split in four rectangular sections. Check the corners 192 // of each of the sections. We check the corners left to right, top to 193 // bottom, like when reading left-to-right text. 194 195 size := img.Bounds().Size() 196 var mismatches []colorMismatch 197 198 checkColor := func(x, y int, want color.Color) { 199 r, g, b, _ := want.RGBA() 200 got := img.At(x, y) 201 r_, g_, b_, _ := got.RGBA() 202 if r_ != r || g_ != g || b_ != b { 203 mismatches = append(mismatches, colorMismatch{ 204 x: x, 205 y: y, 206 wantRGB: [3]uint32{r, g, b}, 207 gotRGB: [3]uint32{r_, g_, b_}, 208 }) 209 } 210 } 211 212 { 213 minX, minY := 5, 5 214 maxX, maxY := (size.X/2)-5, (size.Y/2)-5 215 checkColor(minX, minY, topLeft) 216 checkColor(maxX, minY, topLeft) 217 checkColor(minX, maxY, topLeft) 218 checkColor(maxX, maxY, topLeft) 219 } 220 { 221 minX, minY := (size.X/2)+5, 5 222 maxX, maxY := size.X-5, (size.Y/2)-5 223 checkColor(minX, minY, topRight) 224 checkColor(maxX, minY, topRight) 225 checkColor(minX, maxY, topRight) 226 checkColor(maxX, maxY, topRight) 227 } 228 { 229 minX, minY := 5, (size.Y/2)+5 230 maxX, maxY := (size.X/2)-5, size.Y-5 231 checkColor(minX, minY, botLeft) 232 checkColor(maxX, minY, botLeft) 233 checkColor(minX, maxY, botLeft) 234 checkColor(maxX, maxY, botLeft) 235 } 236 { 237 minX, minY := (size.X/2)+5, (size.Y/2)+5 238 maxX, maxY := size.X-5, size.Y-5 239 checkColor(minX, minY, botRight) 240 checkColor(maxX, minY, botRight) 241 checkColor(minX, maxY, botRight) 242 checkColor(maxX, maxY, botRight) 243 } 244 if n := len(mismatches); n > 0 { 245 b := new(strings.Builder) 246 fmt.Fprintf(b, "encountered %d color mismatches:\n", n) 247 for _, m := range mismatches { 248 fmt.Fprintf(b, "%s\n", m) 249 } 250 return errors.New(b.String()) 251 } 252 return nil 253 } 254 255 func (d *driverBase) waitForFrame() { 256 d.Helper() 257 258 if d.frameNotifs == nil { 259 // Start the goroutine that reads output lines and notifies of 260 // new frames via frameNotifs. The test doesn't wait for this 261 // goroutine to finish; it will naturally end when the output 262 // reader reaches an error like EOF. 263 d.frameNotifs = make(chan bool, 1) 264 if d.output == nil { 265 d.Fatal("need an output reader to be notified of frames") 266 } 267 go func() { 268 scanner := bufio.NewScanner(d.output) 269 for scanner.Scan() { 270 line := scanner.Text() 271 d.Log(line) 272 if strings.Contains(line, "gio frame ready") { 273 d.frameNotifs <- true 274 } 275 } 276 // Since we're only interested in the output while the 277 // app runs, and we don't know when it finishes here, 278 // ignore "already closed" pipe errors. 279 if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) { 280 d.Errorf("reading app output: %v", err) 281 } 282 }() 283 } 284 285 // Unfortunately, there isn't a way to select on a test failing, since 286 // testing.T doesn't have anything like a context or a "done" channel. 287 // 288 // We can't let selects block forever, since the default -test.timeout 289 // is ten minutes - far too long for tests that take seconds. 290 // 291 // For now, a static short timeout is better than nothing. 5s is plenty 292 // for our simple test app to render on any device. 293 select { 294 case <-d.frameNotifs: 295 case <-time.After(5 * time.Second): 296 d.Fatalf("timed out waiting for a frame to be ready") 297 } 298 } 299 300 func (d *driverBase) needPrograms(names ...string) { 301 d.Helper() 302 for _, name := range names { 303 if _, err := exec.LookPath(name); err != nil { 304 d.Skipf("%s needed to run", name) 305 } 306 } 307 } 308 309 func (d *driverBase) tempDir(name string) string { 310 d.Helper() 311 dir, err := ioutil.TempDir("", name) 312 if err != nil { 313 d.Fatal(err) 314 } 315 d.Cleanup(func() { os.RemoveAll(dir) }) 316 return dir 317 } 318 319 func (d *driverBase) gogio(args ...string) { 320 d.Helper() 321 prog, err := os.Executable() 322 if err != nil { 323 d.Fatal(err) 324 } 325 cmd := exec.Command(prog, args...) 326 cmd.Env = append(os.Environ(), "RUN_GOGIO=1") 327 if out, err := cmd.CombinedOutput(); err != nil { 328 d.Fatalf("gogio error: %s:\n%s", err, out) 329 } 330 }