test(examples): headless NeferWL tests for the layer and lock examples
- commit
- 543bef230b5ba331758bb347c1f017c2ada7b943
- author
- brice · · 2026-10-01 19:13 UTC
- parent
- b46ec2f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +221 -0
A examples/headless_test.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | package examples_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "image" | |
| 8 | + | "image/png" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "os/exec" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 14 | + | "syscall" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The tests run the example programs against a NeferWL compositor started | |
| 20 | + | // headless in an isolated runtime directory, and are skipped unless | |
| 21 | + | // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to | |
| 22 | + | // that nested compositor: the child environment is stripped of the caller's | |
| 23 | + | // session variables, and the socket is checked to live in the temporary | |
| 24 | + | // directory. Never point these tests at a real session. | |
| 25 | + | ||
| 26 | + | var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | |
| 27 | + | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | |
| 28 | + | "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"} | |
| 29 | + | ||
| 30 | + | func cleanEnv() []string { | |
| 31 | + | var out []string | |
| 32 | + | next: | |
| 33 | + | for _, e := range os.Environ() { | |
| 34 | + | for _, p := range envDrop { | |
| 35 | + | if strings.HasPrefix(e, p) { | |
| 36 | + | continue next | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | out = append(out, e) | |
| 40 | + | } | |
| 41 | + | return out | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func must(t *testing.T, err error, what ...any) { | |
| 45 | + | t.Helper() | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatalf("%v: %v", fmt.Sprint(what...), err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type compositor struct { | |
| 52 | + | socket, shots string | |
| 53 | + | env []string | |
| 54 | + | input io.WriteCloser // NeferWL input script (stdin) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // startHeadless runs NeferWL and returns once its socket exists. | |
| 58 | + | func startHeadless(t *testing.T) *compositor { | |
| 59 | + | t.Helper() | |
| 60 | + | bin := os.Getenv("NEFERCLIENT_HEADLESS") | |
| 61 | + | if bin == "" { | |
| 62 | + | t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary") | |
| 63 | + | } | |
| 64 | + | bin, err := filepath.Abs(bin) | |
| 65 | + | must(t, err) | |
| 66 | + | root := t.TempDir() | |
| 67 | + | dirs := map[string]string{} | |
| 68 | + | for _, name := range []string{"run", "config", "data", "state", "shots"} { | |
| 69 | + | dirs[name] = filepath.Join(root, name) | |
| 70 | + | must(t, os.Mkdir(dirs[name], 0o700)) | |
| 71 | + | } | |
| 72 | + | logf, err := os.Create(filepath.Join(root, "neferwl.log")) | |
| 73 | + | must(t, err) | |
| 74 | + | t.Cleanup(func() { _ = logf.Close() }) | |
| 75 | + | env := append(cleanEnv(), "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"], | |
| 76 | + | "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"]) | |
| 77 | + | cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480", | |
| 78 | + | "--timeout", "60s", "--screenshot", dirs["shots"], "--input", "-") | |
| 79 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 80 | + | cmd.Env = env | |
| 81 | + | cmd.Stdout, cmd.Stderr = logf, logf | |
| 82 | + | stdin, err := cmd.StdinPipe() | |
| 83 | + | must(t, err) | |
| 84 | + | must(t, cmd.Start()) | |
| 85 | + | exited := make(chan struct{}) | |
| 86 | + | go func() { _ = cmd.Wait(); close(exited) }() | |
| 87 | + | t.Cleanup(func() { | |
| 88 | + | _ = stdin.Close() | |
| 89 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 90 | + | <-exited | |
| 91 | + | }) | |
| 92 | + | deadline := time.Now().Add(15 * time.Second) | |
| 93 | + | for { | |
| 94 | + | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | |
| 95 | + | for _, p := range paths { | |
| 96 | + | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | |
| 97 | + | if !strings.HasPrefix(p, root) { | |
| 98 | + | t.Fatalf("socket %s is outside the test directory", p) | |
| 99 | + | } | |
| 100 | + | return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin} | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | select { | |
| 104 | + | case <-exited: | |
| 105 | + | b, _ := os.ReadFile(logf.Name()) | |
| 106 | + | t.Fatalf("neferwl exited before serving:\n%s", b) | |
| 107 | + | case <-time.After(5 * time.Millisecond): | |
| 108 | + | } | |
| 109 | + | if time.Now().After(deadline) { | |
| 110 | + | b, _ := os.ReadFile(logf.Name()) | |
| 111 | + | t.Fatalf("headless socket timeout:\n%s", b) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // build compiles one example into a temporary directory. | |
| 117 | + | func build(t *testing.T, pkg string) string { | |
| 118 | + | t.Helper() | |
| 119 | + | exe := filepath.Join(t.TempDir(), filepath.Base(pkg)) | |
| 120 | + | out, err := exec.Command("go", "build", "-o", exe, pkg).CombinedOutput() | |
| 121 | + | must(t, err, string(out)) | |
| 122 | + | return exe | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // command prepares an example to run against c, and only against c. | |
| 126 | + | func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd { | |
| 127 | + | cmd := exec.CommandContext(ctx, exe, args...) | |
| 128 | + | cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket) | |
| 129 | + | return cmd | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func near(a, b uint32) bool { | |
| 133 | + | d := int(a>>8) - int(b>>8) | |
| 134 | + | return d >= -2 && d <= 2 | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // hasColor reports whether the PNG has the (r, g, b) colour, tolerance 2 per | |
| 138 | + | // channel, at (x, y). | |
| 139 | + | func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool { | |
| 140 | + | t.Helper() | |
| 141 | + | f, err := os.Open(path) | |
| 142 | + | must(t, err) | |
| 143 | + | defer f.Close() | |
| 144 | + | img, err := png.Decode(f) | |
| 145 | + | if err != nil { // a frame being written when we look | |
| 146 | + | return false | |
| 147 | + | } | |
| 148 | + | if !image.Pt(x, y).In(img.Bounds()) { | |
| 149 | + | return false | |
| 150 | + | } | |
| 151 | + | pr, pg, pb, _ := img.At(x, y).RGBA() | |
| 152 | + | return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestHeadlessLayer(t *testing.T) { | |
| 156 | + | c := startHeadless(t) | |
| 157 | + | exe := build(t, "./layer") | |
| 158 | + | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | |
| 159 | + | defer cancel() | |
| 160 | + | cmd := c.command(ctx, exe, "-frames", "20") | |
| 161 | + | out, err := cmd.CombinedOutput() | |
| 162 | + | must(t, err, string(out)) | |
| 163 | + | ||
| 164 | + | // The button is 200x40 logical pixels at (10, 40) of a surface placed at | |
| 165 | + | // the output's top-left corner, filled with #2060c0. Sample near its | |
| 166 | + | // bottom-right corner, away from the label. The program may already have | |
| 167 | + | // exited, so look through every screenshot taken while it was mapped. | |
| 168 | + | deadline := time.Now().Add(10 * time.Second) | |
| 169 | + | for { | |
| 170 | + | shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png")) | |
| 171 | + | for _, p := range shots { | |
| 172 | + | if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) { | |
| 173 | + | t.Logf("button colour found in %s", filepath.Base(p)) | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | if time.Now().After(deadline) { | |
| 178 | + | t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots)) | |
| 179 | + | } | |
| 180 | + | time.Sleep(50 * time.Millisecond) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestHeadlessLock(t *testing.T) { | |
| 185 | + | c := startHeadless(t) | |
| 186 | + | exe := build(t, "./lock") | |
| 187 | + | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | |
| 188 | + | defer cancel() | |
| 189 | + | cmd := c.command(ctx, exe) | |
| 190 | + | stdout, err := cmd.StdoutPipe() | |
| 191 | + | must(t, err) | |
| 192 | + | var stderr strings.Builder | |
| 193 | + | cmd.Stderr = &stderr | |
| 194 | + | must(t, cmd.Start()) | |
| 195 | + | lines := make(chan string, 8) | |
| 196 | + | go func() { | |
| 197 | + | sc := bufio.NewScanner(stdout) | |
| 198 | + | for sc.Scan() { | |
| 199 | + | lines <- sc.Text() | |
| 200 | + | } | |
| 201 | + | close(lines) | |
| 202 | + | }() | |
| 203 | + | expect := func(want string) { | |
| 204 | + | t.Helper() | |
| 205 | + | select { | |
| 206 | + | case l := <-lines: | |
| 207 | + | if l != want { | |
| 208 | + | t.Fatalf("got %q, want %q: %s", l, want, stderr.String()) | |
| 209 | + | } | |
| 210 | + | case <-ctx.Done(): | |
| 211 | + | t.Fatalf("timeout waiting for %q: %s", want, stderr.String()) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | expect("locked") | |
| 215 | + | // Only the nested compositor receives this input; it types into the | |
| 216 | + | // password field (shown masked) and presses Enter. | |
| 217 | + | _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n") | |
| 218 | + | must(t, err) | |
| 219 | + | expect("unlocked") | |
| 220 | + | must(t, cmd.Wait(), stderr.String()) | |
| 221 | + | } | |
| @@ -0,0 +1,221 @@ | |||||
| 1 | + | package examples_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "image" | |||
| 8 | + | "image/png" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | "os/exec" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 14 | + | "syscall" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // The tests run the example programs against a NeferWL compositor started | |||
| 20 | + | // headless in an isolated runtime directory, and are skipped unless | |||
| 21 | + | // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to | |||
| 22 | + | // that nested compositor: the child environment is stripped of the caller's | |||
| 23 | + | // session variables, and the socket is checked to live in the temporary | |||
| 24 | + | // directory. Never point these tests at a real session. | |||
| 25 | + | ||||
| 26 | + | var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=", | |||
| 27 | + | "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=", | |||
| 28 | + | "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"} | |||
| 29 | + | ||||
| 30 | + | func cleanEnv() []string { | |||
| 31 | + | var out []string | |||
| 32 | + | next: | |||
| 33 | + | for _, e := range os.Environ() { | |||
| 34 | + | for _, p := range envDrop { | |||
| 35 | + | if strings.HasPrefix(e, p) { | |||
| 36 | + | continue next | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | out = append(out, e) | |||
| 40 | + | } | |||
| 41 | + | return out | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func must(t *testing.T, err error, what ...any) { | |||
| 45 | + | t.Helper() | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatalf("%v: %v", fmt.Sprint(what...), err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | type compositor struct { | |||
| 52 | + | socket, shots string | |||
| 53 | + | env []string | |||
| 54 | + | input io.WriteCloser // NeferWL input script (stdin) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // startHeadless runs NeferWL and returns once its socket exists. | |||
| 58 | + | func startHeadless(t *testing.T) *compositor { | |||
| 59 | + | t.Helper() | |||
| 60 | + | bin := os.Getenv("NEFERCLIENT_HEADLESS") | |||
| 61 | + | if bin == "" { | |||
| 62 | + | t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary") | |||
| 63 | + | } | |||
| 64 | + | bin, err := filepath.Abs(bin) | |||
| 65 | + | must(t, err) | |||
| 66 | + | root := t.TempDir() | |||
| 67 | + | dirs := map[string]string{} | |||
| 68 | + | for _, name := range []string{"run", "config", "data", "state", "shots"} { | |||
| 69 | + | dirs[name] = filepath.Join(root, name) | |||
| 70 | + | must(t, os.Mkdir(dirs[name], 0o700)) | |||
| 71 | + | } | |||
| 72 | + | logf, err := os.Create(filepath.Join(root, "neferwl.log")) | |||
| 73 | + | must(t, err) | |||
| 74 | + | t.Cleanup(func() { _ = logf.Close() }) | |||
| 75 | + | env := append(cleanEnv(), "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"], | |||
| 76 | + | "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"]) | |||
| 77 | + | cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480", | |||
| 78 | + | "--timeout", "60s", "--screenshot", dirs["shots"], "--input", "-") | |||
| 79 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 80 | + | cmd.Env = env | |||
| 81 | + | cmd.Stdout, cmd.Stderr = logf, logf | |||
| 82 | + | stdin, err := cmd.StdinPipe() | |||
| 83 | + | must(t, err) | |||
| 84 | + | must(t, cmd.Start()) | |||
| 85 | + | exited := make(chan struct{}) | |||
| 86 | + | go func() { _ = cmd.Wait(); close(exited) }() | |||
| 87 | + | t.Cleanup(func() { | |||
| 88 | + | _ = stdin.Close() | |||
| 89 | + | _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 90 | + | <-exited | |||
| 91 | + | }) | |||
| 92 | + | deadline := time.Now().Add(15 * time.Second) | |||
| 93 | + | for { | |||
| 94 | + | paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*")) | |||
| 95 | + | for _, p := range paths { | |||
| 96 | + | if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 { | |||
| 97 | + | if !strings.HasPrefix(p, root) { | |||
| 98 | + | t.Fatalf("socket %s is outside the test directory", p) | |||
| 99 | + | } | |||
| 100 | + | return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin} | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | select { | |||
| 104 | + | case <-exited: | |||
| 105 | + | b, _ := os.ReadFile(logf.Name()) | |||
| 106 | + | t.Fatalf("neferwl exited before serving:\n%s", b) | |||
| 107 | + | case <-time.After(5 * time.Millisecond): | |||
| 108 | + | } | |||
| 109 | + | if time.Now().After(deadline) { | |||
| 110 | + | b, _ := os.ReadFile(logf.Name()) | |||
| 111 | + | t.Fatalf("headless socket timeout:\n%s", b) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // build compiles one example into a temporary directory. | |||
| 117 | + | func build(t *testing.T, pkg string) string { | |||
| 118 | + | t.Helper() | |||
| 119 | + | exe := filepath.Join(t.TempDir(), filepath.Base(pkg)) | |||
| 120 | + | out, err := exec.Command("go", "build", "-o", exe, pkg).CombinedOutput() | |||
| 121 | + | must(t, err, string(out)) | |||
| 122 | + | return exe | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // command prepares an example to run against c, and only against c. | |||
| 126 | + | func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd { | |||
| 127 | + | cmd := exec.CommandContext(ctx, exe, args...) | |||
| 128 | + | cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket) | |||
| 129 | + | return cmd | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func near(a, b uint32) bool { | |||
| 133 | + | d := int(a>>8) - int(b>>8) | |||
| 134 | + | return d >= -2 && d <= 2 | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // hasColor reports whether the PNG has the (r, g, b) colour, tolerance 2 per | |||
| 138 | + | // channel, at (x, y). | |||
| 139 | + | func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool { | |||
| 140 | + | t.Helper() | |||
| 141 | + | f, err := os.Open(path) | |||
| 142 | + | must(t, err) | |||
| 143 | + | defer f.Close() | |||
| 144 | + | img, err := png.Decode(f) | |||
| 145 | + | if err != nil { // a frame being written when we look | |||
| 146 | + | return false | |||
| 147 | + | } | |||
| 148 | + | if !image.Pt(x, y).In(img.Bounds()) { | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | pr, pg, pb, _ := img.At(x, y).RGBA() | |||
| 152 | + | return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestHeadlessLayer(t *testing.T) { | |||
| 156 | + | c := startHeadless(t) | |||
| 157 | + | exe := build(t, "./layer") | |||
| 158 | + | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | |||
| 159 | + | defer cancel() | |||
| 160 | + | cmd := c.command(ctx, exe, "-frames", "20") | |||
| 161 | + | out, err := cmd.CombinedOutput() | |||
| 162 | + | must(t, err, string(out)) | |||
| 163 | + | ||||
| 164 | + | // The button is 200x40 logical pixels at (10, 40) of a surface placed at | |||
| 165 | + | // the output's top-left corner, filled with #2060c0. Sample near its | |||
| 166 | + | // bottom-right corner, away from the label. The program may already have | |||
| 167 | + | // exited, so look through every screenshot taken while it was mapped. | |||
| 168 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 169 | + | for { | |||
| 170 | + | shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png")) | |||
| 171 | + | for _, p := range shots { | |||
| 172 | + | if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) { | |||
| 173 | + | t.Logf("button colour found in %s", filepath.Base(p)) | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | if time.Now().After(deadline) { | |||
| 178 | + | t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots)) | |||
| 179 | + | } | |||
| 180 | + | time.Sleep(50 * time.Millisecond) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestHeadlessLock(t *testing.T) { | |||
| 185 | + | c := startHeadless(t) | |||
| 186 | + | exe := build(t, "./lock") | |||
| 187 | + | ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) | |||
| 188 | + | defer cancel() | |||
| 189 | + | cmd := c.command(ctx, exe) | |||
| 190 | + | stdout, err := cmd.StdoutPipe() | |||
| 191 | + | must(t, err) | |||
| 192 | + | var stderr strings.Builder | |||
| 193 | + | cmd.Stderr = &stderr | |||
| 194 | + | must(t, cmd.Start()) | |||
| 195 | + | lines := make(chan string, 8) | |||
| 196 | + | go func() { | |||
| 197 | + | sc := bufio.NewScanner(stdout) | |||
| 198 | + | for sc.Scan() { | |||
| 199 | + | lines <- sc.Text() | |||
| 200 | + | } | |||
| 201 | + | close(lines) | |||
| 202 | + | }() | |||
| 203 | + | expect := func(want string) { | |||
| 204 | + | t.Helper() | |||
| 205 | + | select { | |||
| 206 | + | case l := <-lines: | |||
| 207 | + | if l != want { | |||
| 208 | + | t.Fatalf("got %q, want %q: %s", l, want, stderr.String()) | |||
| 209 | + | } | |||
| 210 | + | case <-ctx.Done(): | |||
| 211 | + | t.Fatalf("timeout waiting for %q: %s", want, stderr.String()) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | expect("locked") | |||
| 215 | + | // Only the nested compositor receives this input; it types into the | |||
| 216 | + | // password field (shown masked) and presses Enter. | |||
| 217 | + | _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n") | |||
| 218 | + | must(t, err) | |||
| 219 | + | expect("unlocked") | |||
| 220 | + | must(t, cmd.Wait(), stderr.String()) | |||
| 221 | + | } | |||