~eliasnaur/gio

ref: 6a9a87046221602d1a323887cd619e75c81cc24e gio/cmd/gogio/e2e_test.go -rw-r--r-- 8.5 KiB
6a9a8704Sebastien Binet app{,/internal/window}: make app.Main blocking on desktop platforms 2 years ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
// SPDX-License-Identifier: Unlicense OR MIT

package main_test

import (
	"bufio"
	"errors"
	"flag"
	"fmt"
	"image"
	"image/color"
	"io"
	"io/ioutil"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

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 {
	initBase(t *testing.T, width, height int)

	// Start opens the Gio app found at path. The driver should attempt to
	// run the app with the base driver's 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.
	Start(path string)

	// Screenshot takes a screenshot of the Gio app on the platform.
	Screenshot() image.Image

	// Click performs a pointer click at the specified coordinates,
	// including both press and release. It returns when the next frame is
	// fully drawn.
	Click(x, y int)
}

type driverBase struct {
	*testing.T

	width, height int

	output      io.Reader
	frameNotifs chan bool
}

func (d *driverBase) initBase(t *testing.T, width, height int) {
	d.T = t
	d.width, d.height = width, height
}

func TestEndToEnd(t *testing.T) {
	if testing.Short() {
		t.Skipf("end-to-end tests tend to be slow")
	}

	t.Parallel()

	// Keep this list local, to not reuse TestDriver objects.
	subtests := []struct {
		name   string
		driver TestDriver
	}{
		{"X11", &X11TestDriver{}},
		{"Wayland", &WaylandTestDriver{}},
		{"JS", &JSTestDriver{}},
		{"Android", &AndroidTestDriver{}},
		{"Windows", &WineTestDriver{}},
	}

	for _, subtest := range subtests {
		t.Run(subtest.name, func(t *testing.T) {
			subtest := subtest // copy the changing loop variable
			t.Parallel()
			runEndToEndTest(t, subtest.driver)
		})
	}
}

func runEndToEndTest(t *testing.T, driver TestDriver) {
	size := image.Point{X: 800, Y: 600}
	driver.initBase(t, size.X, size.Y)

	t.Log("starting driver and gio app")
	driver.Start("testdata/red.go")

	beef := color.RGBA{R: 0xde, G: 0xad, B: 0xbe}
	white := color.RGBA{R: 0xff, G: 0xff, B: 0xff}
	black := color.RGBA{R: 0x00, G: 0x00, B: 0x00}
	gray := color.RGBA{R: 0xbb, G: 0xbb, B: 0xbb}
	red := color.RGBA{R: 0xff, G: 0x00, B: 0x00}

	// These are the four colors at the beginning.
	t.Log("taking initial screenshot")
	withRetries(t, 2*time.Second, func() error {
		img := driver.Screenshot()
		size = img.Bounds().Size() // override the default size
		return checkImageCorners(img, beef, white, black, gray)
	})

	// TODO(mvdan): implement this properly in the Wayland driver; swaymsg
	// almost works to automate clicks, but the button presses end up in the
	// wrong coordinates.
	if _, ok := driver.(*WaylandTestDriver); ok {
		return
	}

	// Click the first and last sections to turn them red.
	t.Log("clicking twice and taking another screenshot")
	driver.Click(1*(size.X/4), 1*(size.Y/4))
	driver.Click(3*(size.X/4), 3*(size.Y/4))
	withRetries(t, 2*time.Second, func() error {
		img := driver.Screenshot()
		return checkImageCorners(img, red, white, black, red)
	})
}

// withRetries keeps retrying fn until it succeeds, or until the timeout is hit.
// It uses a rudimentary kind of backoff, which starts with 100ms delays. As
// such, timeout should generally be in the order of seconds.
func withRetries(t *testing.T, timeout time.Duration, fn func() error) {
	t.Helper()

	timeoutTimer := time.NewTimer(timeout)
	defer timeoutTimer.Stop()
	backoff := 100 * time.Millisecond

	tries := 0
	var lastErr error
	for {
		if lastErr = fn(); lastErr == nil {
			return
		}
		tries++
		t.Logf("retrying after %s", backoff)

		// Use a timer instead of a sleep, so that the timeout can stop
		// the backoff early. Don't reuse this timer, since we're not in
		// a hot loop, and we don't want tricky code.
		backoffTimer := time.NewTimer(backoff)
		defer backoffTimer.Stop()

		select {
		case <-timeoutTimer.C:
			t.Errorf("last error: %v", lastErr)
			t.Fatalf("hit timeout of %s after %d tries", timeout, tries)
		case <-backoffTimer.C:
		}

		// Keep doubling it until a maximum. With the start at 100ms,
		// we'll do: 100ms, 200ms, 400ms, 800ms, 1.6s, and 2s forever.
		backoff *= 2
		if max := 2 * time.Second; backoff > max {
			backoff = max
		}
	}
}

type colorMismatch struct {
	x, y            int
	wantRGB, gotRGB [3]uint32
}

func (m colorMismatch) String() string {
	return fmt.Sprintf("%3d,%-3d got 0x%04x%04x%04x, want 0x%04x%04x%04x",
		m.x, m.y,
		m.gotRGB[0], m.gotRGB[1], m.gotRGB[2],
		m.wantRGB[0], m.wantRGB[1], m.wantRGB[2],
	)
}

func checkImageCorners(img image.Image, topLeft, topRight, botLeft, botRight color.RGBA) error {
	// The colors are split in four rectangular sections. Check the corners
	// of each of the sections. We check the corners left to right, top to
	// bottom, like when reading left-to-right text.

	size := img.Bounds().Size()
	var mismatches []colorMismatch

	checkColor := func(x, y int, want color.Color) {
		r, g, b, _ := want.RGBA()
		got := img.At(x, y)
		r_, g_, b_, _ := got.RGBA()
		if r_ != r || g_ != g || b_ != b {
			mismatches = append(mismatches, colorMismatch{
				x:       x,
				y:       y,
				wantRGB: [3]uint32{r, g, b},
				gotRGB:  [3]uint32{r_, g_, b_},
			})
		}
	}

	{
		minX, minY := 5, 5
		maxX, maxY := (size.X/2)-5, (size.Y/2)-5
		checkColor(minX, minY, topLeft)
		checkColor(maxX, minY, topLeft)
		checkColor(minX, maxY, topLeft)
		checkColor(maxX, maxY, topLeft)
	}
	{
		minX, minY := (size.X/2)+5, 5
		maxX, maxY := size.X-5, (size.Y/2)-5
		checkColor(minX, minY, topRight)
		checkColor(maxX, minY, topRight)
		checkColor(minX, maxY, topRight)
		checkColor(maxX, maxY, topRight)
	}
	{
		minX, minY := 5, (size.Y/2)+5
		maxX, maxY := (size.X/2)-5, size.Y-5
		checkColor(minX, minY, botLeft)
		checkColor(maxX, minY, botLeft)
		checkColor(minX, maxY, botLeft)
		checkColor(maxX, maxY, botLeft)
	}
	{
		minX, minY := (size.X/2)+5, (size.Y/2)+5
		maxX, maxY := size.X-5, size.Y-5
		checkColor(minX, minY, botRight)
		checkColor(maxX, minY, botRight)
		checkColor(minX, maxY, botRight)
		checkColor(maxX, maxY, botRight)
	}
	if n := len(mismatches); n > 0 {
		b := new(strings.Builder)
		fmt.Fprintf(b, "encountered %d color mismatches:\n", n)
		for _, m := range mismatches {
			fmt.Fprintf(b, "%s\n", m)
		}
		return errors.New(b.String())
	}
	return nil
}

func (d *driverBase) waitForFrame() {
	d.Helper()

	if d.frameNotifs == nil {
		// Start the goroutine that reads output lines and notifies of
		// new frames via frameNotifs. The test doesn't wait for this
		// goroutine to finish; it will naturally end when the output
		// reader reaches an error like EOF.
		d.frameNotifs = make(chan bool, 1)
		if d.output == nil {
			d.Fatal("need an output reader to be notified of frames")
		}
		go func() {
			scanner := bufio.NewScanner(d.output)
			for scanner.Scan() {
				line := scanner.Text()
				if strings.Contains(line, "gio frame ready") {
					d.frameNotifs <- true
				}
			}
			// Since we're only interested in the output while the
			// app runs, and we don't know when it finishes here,
			// ignore "already closed" pipe errors.
			if err := scanner.Err(); err != nil && !errors.Is(err, os.ErrClosed) {
				d.Errorf("reading app output: %v", err)
			}
		}()
	}

	// Unfortunately, there isn't a way to select on a test failing, since
	// testing.T doesn't have anything like a context or a "done" channel.
	//
	// We can't let selects block forever, since the default -test.timeout
	// is ten minutes - far too long for tests that take seconds.
	//
	// For now, a static short timeout is better than nothing. 5s is plenty
	// for our simple test app to render on any device.
	select {
	case <-d.frameNotifs:
	case <-time.After(5 * time.Second):
		d.Fatalf("timed out waiting for a frame to be ready")
	}
}

func (d *driverBase) needPrograms(names ...string) {
	d.Helper()
	for _, name := range names {
		if _, err := exec.LookPath(name); err != nil {
			d.Skipf("%s needed to run", name)
		}
	}
}

func (d *driverBase) tempDir(name string) string {
	d.Helper()
	dir, err := ioutil.TempDir("", name)
	if err != nil {
		d.Fatal(err)
	}
	d.Cleanup(func() { os.RemoveAll(dir) })
	return dir
}

func (d *driverBase) gogio(args ...string) {
	d.Helper()
	prog, err := os.Executable()
	if err != nil {
		d.Fatal(err)
	}
	cmd := exec.Command(prog, args...)
	cmd.Env = append(os.Environ(), "RUN_GOGIO=1")
	if out, err := cmd.CombinedOutput(); err != nil {
		d.Fatalf("gogio error: %s:\n%s", err, out)
	}
}