1package app
2
3import (
4 "bytes"
5 "context"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/bnema/neferwl/internal/adapters/config"
15 "github.com/bnema/wlturbo"
16 clientcore "github.com/bnema/wlturbo/protocol/core"
17 clienttext "github.com/bnema/wlturbo/protocol/textinput"
18 clientxdg "github.com/bnema/wlturbo/protocol/xdgshell"
19 "golang.org/x/sys/unix"
20)
21
22// lockedBuffer is an io.Writer read while exec writes to it.
23type lockedBuffer struct {
24 mu sync.Mutex
25 buf bytes.Buffer
26}
27
28func (b *lockedBuffer) Write(p []byte) (int, error) {
29 b.mu.Lock()
30 defer b.mu.Unlock()
31 return b.buf.Write(p)
32}
33
34func (b *lockedBuffer) String() string {
35 b.mu.Lock()
36 defer b.mu.Unlock()
37 return b.buf.String()
38}
39
40// textClient retains observations from an ordinary generated text-input proxy.
41type textClient struct {
42 *clienttext.TextInputV3
43 entered bool
44 preedit []string
45 commits []string
46}
47
48// NeferWL headless, the examples/testime input method and a text input
49// client: the input method's preedit and commit reach the client.
50func TestHeadlessTextInputIME(t *testing.T) {
51 runtime := t.TempDir()
52 t.Setenv("XDG_RUNTIME_DIR", runtime)
53 t.Setenv("XDG_STATE_HOME", t.TempDir())
54 ime := filepath.Join(t.TempDir(), "testime")
55 build := exec.Command("go", "build", "-o", ime, "../../examples/testime")
56 build.Env = append(os.Environ(), "CGO_ENABLED=0")
57 if out, err := build.CombinedOutput(); err != nil {
58 t.Fatalf("build testime: %v: %s", err, out)
59 }
60
61 ctx, cancel := context.WithCancel(context.Background())
62 done := make(chan error, 1)
63 go func() {
64 done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults()})
65 }()
66 stopped := false // Run's result was already received
67 defer func() {
68 cancel()
69 if stopped {
70 return
71 }
72 select {
73 case err := <-done:
74 if err != nil {
75 t.Errorf("Run: %v", err)
76 }
77 case <-time.After(5 * time.Second):
78 t.Error("Run did not stop")
79 }
80 }()
81 socket := filepath.Join(runtime, "wayland-1")
82 for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {
83 if _, err := os.Stat(socket); err == nil {
84 break
85 }
86 select {
87 case err := <-done:
88 stopped = true
89 if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
90 t.Skipf("Vulkan unavailable: %v", err)
91 }
92 t.Fatalf("neferwl exited: %v", err)
93 default:
94 }
95 if time.Now().After(deadline) {
96 t.Fatal("no wayland socket")
97 }
98 }
99
100 // Two commits: the second comes from enabling the text input again.
101 cmd := exec.Command(ime, "-count", "2", "-preedit", "nihon", "-commit", "日本")
102 cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY=wayland-1")
103 imeOut := &lockedBuffer{}
104 cmd.Stderr = imeOut
105 if err := cmd.Start(); err != nil {
106 t.Fatal(err)
107 }
108 imeDone := make(chan error, 1)
109 go func() { imeDone <- cmd.Wait() }()
110 imeExited := false
111 defer func() {
112 if !imeExited {
113 _ = cmd.Process.Kill()
114 <-imeDone
115 }
116 }()
117 for deadline := time.Now().Add(5 * time.Second); !strings.Contains(imeOut.String(), "ready"); time.Sleep(10 * time.Millisecond) {
118 if time.Now().After(deadline) {
119 t.Fatalf("testime not ready: %s", imeOut.String())
120 }
121 }
122
123 c, err := wlturbo.Connect(socket)
124 if err != nil {
125 t.Fatal(err)
126 }
127 defer c.Close()
128 text := mapTextClient(t, c)
129 poll := func(what string, ok func() bool) {
130 t.Helper()
131 for deadline := time.Now().Add(5 * time.Second); !ok(); time.Sleep(10 * time.Millisecond) {
132 if time.Now().After(deadline) {
133 t.Fatalf("%s: preedit %q commits %q, testime: %s", what, text.preedit, text.commits, imeOut.String())
134 }
135 if err := c.Roundtrip(); err != nil {
136 t.Fatal(err)
137 }
138 }
139 }
140 poll("no text input enter", func() bool { return text.entered })
141 for i := 1; i <= 2; i++ {
142 if err := text.Enable(); err != nil {
143 t.Fatal(err)
144 }
145 if err := text.SetContentType(0, 0); err != nil {
146 t.Fatal(err)
147 }
148 if err := text.Commit(); err != nil {
149 t.Fatal(err)
150 }
151 poll("missing commit", func() bool { return len(text.commits) == i })
152 }
153 if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" {
154 t.Fatalf("preedit %q commits %q", text.preedit, text.commits)
155 }
156 select {
157 case err := <-imeDone:
158 imeExited = true
159 if err != nil {
160 t.Fatalf("testime: %v: %s", err, imeOut.String())
161 }
162 case <-time.After(5 * time.Second):
163 t.Fatalf("testime did not exit after two commits: %s", imeOut.String())
164 }
165}
166
167// mapTextClient maps a 1x1 toplevel with a text input on c.
168func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient {
169 t.Helper()
170 check := func(err error) {
171 t.Helper()
172 if err != nil {
173 t.Fatal(err)
174 }
175 }
176 check(c.Roundtrip())
177 comp := clientcore.NewCompositor(c.Context())
178 shm := clientcore.NewShm(c.Context())
179 seat := clientcore.NewSeat(c.Context())
180 tim := clienttext.NewTextInputManagerV3(c.Context())
181 wm := clientxdg.NewXdgWmBase(c.Context())
182 wm.OnPing(func(serial uint32) { check(wm.Pong(serial)) })
183 bindTestClient(t, c, clientcore.CompositorInterface, 1, comp)
184 bindTestClient(t, c, clientcore.ShmInterface, 1, shm)
185 bindTestClient(t, c, clientcore.SeatInterface, 1, seat)
186 bindTestClient(t, c, clienttext.TextInputManagerV3Interface, 1, tim)
187 bindTestClient(t, c, clientxdg.XdgWmBaseInterface, 1, wm)
188 fd, err := unix.MemfdCreate("text-client", 0)
189 check(err)
190 if err := unix.Ftruncate(fd, 4); err != nil {
191 _ = unix.Close(fd)
192 t.Fatal(err)
193 }
194 // CreatePool owns the sent descriptor on success; only close it on error.
195 pool, err := shm.CreatePool(fd, 4)
196 if err != nil {
197 _ = unix.Close(fd)
198 t.Fatal(err)
199 }
200 buffer, err := pool.CreateBuffer(0, 1, 1, 4, clientcore.FORMAT_ARGB8888)
201 check(err)
202 surf, err := comp.CreateSurface()
203 check(err)
204 xdg, err := wm.GetXdgSurface(surf)
205 check(err)
206 var serial uint32
207 xdg.OnConfigure(func(value uint32) { serial = value })
208 _, err = xdg.GetToplevel()
209 check(err)
210 proxy, err := tim.GetTextInput(seat)
211 check(err)
212 text := &textClient{TextInputV3: proxy}
213 proxy.OnEnter(func(uint32) { text.entered = true })
214 proxy.OnPreeditString(func(value string, _, _ int32) { text.preedit = append(text.preedit, value) })
215 proxy.OnCommitString(func(value string) { text.commits = append(text.commits, value) })
216 check(surf.Commit())
217 for deadline := time.Now().Add(5 * time.Second); serial == 0; time.Sleep(10 * time.Millisecond) {
218 if time.Now().After(deadline) {
219 t.Fatal("no configure")
220 }
221 check(c.Roundtrip())
222 }
223 check(xdg.AckConfigure(serial))
224 check(surf.Attach(buffer, 0, 0))
225 check(surf.Commit())
226 return text
227}