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}