sync_client_integration_test.go
1package ygo_test
2
3import (
4 "context"
5 "fmt"
6 "net"
7 "os"
8 "os/exec"
9 "syscall"
10 "testing"
11 "time"
12
13 "git.kilimanjaro.io/ygo"
14 "git.kilimanjaro.io/ygo/ysweet"
15)
16
17func skipIfNoIntegrationTests(t *testing.T) {
18 if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
19 t.Skip("Skipping integration test: set RUN_INTEGRATION_TESTS=1 to run")
20 }
21}
22
23// getFreePort returns a random available port
24func getFreePort() (int, error) {
25 addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
26 if err != nil {
27 return 0, err
28 }
29 l, err := net.ListenTCP("tcp", addr)
30 if err != nil {
31 return 0, err
32 }
33 defer l.Close()
34 return l.Addr().(*net.TCPAddr).Port, nil
35}
36
37func TestSyncClientDisconnectReasonYSweetInitiated(t *testing.T) {
38 skipIfNoIntegrationTests(t)
39
40 // Get a random available port
41 port, err := getFreePort()
42 if err != nil {
43 t.Fatalf("failed to get free port: %v", err)
44 }
45
46 // Start y-sweet server on random port
47 ctx, cancel := context.WithCancel(context.Background())
48 defer cancel()
49
50 cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
51 cmd.SysProcAttr = &syscall.SysProcAttr{
52 Setpgid: true,
53 }
54
55 if err := cmd.Start(); err != nil {
56 t.Fatalf("failed to start y-sweet server: %v", err)
57 }
58
59 // Give server time to start
60 time.Sleep(10 * time.Second)
61
62 // Create client and connect
63 client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
64 if err != nil {
65 t.Fatalf("failed to create y-sweet client: %v", err)
66 }
67
68 docID, err := client.NewDoc("")
69 if err != nil {
70 t.Fatalf("failed to create document: %v", err)
71 }
72
73 auth, err := client.AuthDoc(docID)
74 if err != nil {
75 t.Fatalf("failed to auth document: %v", err)
76 }
77
78 doc, _ := ygo.NewDoc()
79 defer doc.Destroy()
80
81 syncClient, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint(auth.WebsocketURL()))
82
83 connectCtx, connectCancel := context.WithTimeout(context.Background(), 5*time.Second)
84 defer connectCancel()
85
86 err = syncClient.Connect(connectCtx)
87 if err != nil {
88 t.Fatalf("failed to connect: %v", err)
89 }
90
91 reasonReceived := make(chan ygo.DisconnectReason, 1)
92
93 syncClient.OnDisconnect(func(d *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
94 reasonReceived <- reason
95 return nil
96 })
97
98 // Shutdown server by sending SIGINT
99 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil {
100 t.Fatalf("failed to kill server: %v", err)
101 }
102
103 // Wait for disconnect with timeout
104 var disconnectReason ygo.DisconnectReason
105 select {
106 case disconnectReason = <-reasonReceived:
107 case <-time.After(5 * time.Second):
108 t.Fatal("timeout waiting for disconnect callback")
109 }
110
111 if disconnectReason != ygo.DisconnectReasonYSweetInitiated {
112 t.Errorf("expected reason YSweetInitiated, got %v (%s)", disconnectReason, disconnectReason.String())
113 }
114
115 syncClient.Close()
116}
117
118func TestSyncClientDisconnectReasonDocumentIdle(t *testing.T) {
119 skipIfNoIntegrationTests(t)
120
121 // Get a random available port
122 port, err := getFreePort()
123 if err != nil {
124 t.Fatalf("failed to get free port: %v", err)
125 }
126
127 // Start y-sweet server on random port
128 ctx, cancel := context.WithCancel(context.Background())
129 defer cancel()
130
131 cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve", fmt.Sprintf("--port=%d", port))
132 cmd.SysProcAttr = &syscall.SysProcAttr{
133 Setpgid: true,
134 }
135
136 if err := cmd.Start(); err != nil {
137 t.Fatalf("failed to start y-sweet server: %v", err)
138 }
139
140 // Give server time to start
141 time.Sleep(10 * time.Second)
142
143 // Create client and connect
144 client, err := ysweet.NewClient(fmt.Sprintf("http://127.0.0.1:%d", port))
145 if err != nil {
146 t.Fatalf("failed to create y-sweet client: %v", err)
147 }
148
149 docID, err := client.NewDoc("")
150 if err != nil {
151 t.Fatalf("failed to create document: %v", err)
152 }
153
154 auth, err := client.AuthDoc(docID)
155 if err != nil {
156 t.Fatalf("failed to auth document: %v", err)
157 }
158
159 doc, _ := ygo.NewDoc()
160 defer doc.Destroy()
161
162 // Set a short idle timeout
163 syncClient, _ := ygo.NewSyncClient(doc,
164 ygo.WithSyncEndpoint(auth.WebsocketURL()),
165 ygo.WithDisconnectOnNoClientsAfter(2*time.Second),
166 )
167
168 connectCtx, connectCancel := context.WithTimeout(context.Background(), 5*time.Second)
169 defer connectCancel()
170
171 err = syncClient.Connect(connectCtx)
172 if err != nil {
173 t.Fatalf("failed to connect: %v", err)
174 }
175
176 reasonReceived := make(chan ygo.DisconnectReason, 1)
177
178 syncClient.OnDisconnect(func(d *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
179 reasonReceived <- reason
180 return nil
181 })
182
183 // Wait for idle timeout disconnect
184 var disconnectReason ygo.DisconnectReason
185 select {
186 case disconnectReason = <-reasonReceived:
187 case <-time.After(10 * time.Second):
188 t.Fatal("timeout waiting for disconnect callback")
189 }
190
191 if disconnectReason != ygo.DisconnectReasonDocumentIdle {
192 t.Errorf("expected reason DocumentIdle, got %v (%s)", disconnectReason, disconnectReason.String())
193 }
194
195 syncClient.Close()
196
197 // Cleanup server
198 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil {
199 t.Logf("warning: failed to kill server: %v", err)
200 }
201}