github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/envd_rpc_test.go (about) 1 //go:build unit 2 3 package sandbox 4 5 import ( 6 "context" 7 "fmt" 8 "io" 9 "net/http" 10 "net/http/httptest" 11 "sync" 12 "testing" 13 "time" 14 15 "connectrpc.com/connect" 16 "google.golang.org/protobuf/types/known/timestamppb" 17 18 "github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/filesystem" 19 "github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/filesystem/filesystemconnect" 20 "github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/process" 21 "github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/process/processconnect" 22 ) 23 24 // --------------------------------------------------------------------------- 25 // testFilesystemHandler — mock ConnectRPC server for Filesystem service 26 // --------------------------------------------------------------------------- 27 28 type testFilesystemHandler struct { 29 filesystemconnect.UnimplementedFilesystemHandler 30 31 statFn func(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) 32 makeDirFn func(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) 33 moveFn func(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) 34 listDirFn func(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) 35 removeFn func(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) 36 watchDirFn func(context.Context, *connect.Request[filesystem.WatchDirRequest], *connect.ServerStream[filesystem.WatchDirResponse]) error 37 } 38 39 func (h *testFilesystemHandler) Stat(ctx context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { 40 if h.statFn != nil { 41 return h.statFn(ctx, req) 42 } 43 return h.UnimplementedFilesystemHandler.Stat(ctx, req) 44 } 45 46 func (h *testFilesystemHandler) MakeDir(ctx context.Context, req *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) { 47 if h.makeDirFn != nil { 48 return h.makeDirFn(ctx, req) 49 } 50 return h.UnimplementedFilesystemHandler.MakeDir(ctx, req) 51 } 52 53 func (h *testFilesystemHandler) Move(ctx context.Context, req *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) { 54 if h.moveFn != nil { 55 return h.moveFn(ctx, req) 56 } 57 return h.UnimplementedFilesystemHandler.Move(ctx, req) 58 } 59 60 func (h *testFilesystemHandler) ListDir(ctx context.Context, req *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) { 61 if h.listDirFn != nil { 62 return h.listDirFn(ctx, req) 63 } 64 return h.UnimplementedFilesystemHandler.ListDir(ctx, req) 65 } 66 67 func (h *testFilesystemHandler) Remove(ctx context.Context, req *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) { 68 if h.removeFn != nil { 69 return h.removeFn(ctx, req) 70 } 71 return h.UnimplementedFilesystemHandler.Remove(ctx, req) 72 } 73 74 func (h *testFilesystemHandler) WatchDir(ctx context.Context, req *connect.Request[filesystem.WatchDirRequest], stream *connect.ServerStream[filesystem.WatchDirResponse]) error { 75 if h.watchDirFn != nil { 76 return h.watchDirFn(ctx, req, stream) 77 } 78 return h.UnimplementedFilesystemHandler.WatchDir(ctx, req, stream) 79 } 80 81 // --------------------------------------------------------------------------- 82 // testProcessHandler — mock ConnectRPC server for Process service 83 // --------------------------------------------------------------------------- 84 85 type testProcessHandler struct { 86 processconnect.UnimplementedProcessHandler 87 88 startFn func(context.Context, *connect.Request[process.StartRequest], *connect.ServerStream[process.StartResponse]) error 89 connectFn func(context.Context, *connect.Request[process.ConnectRequest], *connect.ServerStream[process.ConnectResponse]) error 90 listFn func(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) 91 sendInputFn func(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) 92 sendSignalFn func(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) 93 updateFn func(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) 94 closeStdinFn func(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) 95 } 96 97 func (h *testProcessHandler) Start(ctx context.Context, req *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 98 if h.startFn != nil { 99 return h.startFn(ctx, req, stream) 100 } 101 return h.UnimplementedProcessHandler.Start(ctx, req, stream) 102 } 103 104 func (h *testProcessHandler) Connect(ctx context.Context, req *connect.Request[process.ConnectRequest], stream *connect.ServerStream[process.ConnectResponse]) error { 105 if h.connectFn != nil { 106 return h.connectFn(ctx, req, stream) 107 } 108 return h.UnimplementedProcessHandler.Connect(ctx, req, stream) 109 } 110 111 func (h *testProcessHandler) List(ctx context.Context, req *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) { 112 if h.listFn != nil { 113 return h.listFn(ctx, req) 114 } 115 return h.UnimplementedProcessHandler.List(ctx, req) 116 } 117 118 func (h *testProcessHandler) SendInput(ctx context.Context, req *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { 119 if h.sendInputFn != nil { 120 return h.sendInputFn(ctx, req) 121 } 122 return h.UnimplementedProcessHandler.SendInput(ctx, req) 123 } 124 125 func (h *testProcessHandler) SendSignal(ctx context.Context, req *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { 126 if h.sendSignalFn != nil { 127 return h.sendSignalFn(ctx, req) 128 } 129 return h.UnimplementedProcessHandler.SendSignal(ctx, req) 130 } 131 132 func (h *testProcessHandler) Update(ctx context.Context, req *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) { 133 if h.updateFn != nil { 134 return h.updateFn(ctx, req) 135 } 136 return h.UnimplementedProcessHandler.Update(ctx, req) 137 } 138 139 func (h *testProcessHandler) CloseStdin(ctx context.Context, req *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { 140 if h.closeStdinFn != nil { 141 return h.closeStdinFn(ctx, req) 142 } 143 return h.UnimplementedProcessHandler.CloseStdin(ctx, req) 144 } 145 146 // --------------------------------------------------------------------------- 147 // helpers — start mock servers and create Filesystem / Commands / Pty 148 // --------------------------------------------------------------------------- 149 150 // newTestFilesystem starts a ConnectRPC mock server and returns a Filesystem 151 // whose rpc client points to it. The caller should call ts.Close() when done. 152 func newTestFilesystem(handler *testFilesystemHandler) (*Filesystem, *httptest.Server) { 153 mux := http.NewServeMux() 154 path, h := filesystemconnect.NewFilesystemHandler(handler) 155 mux.Handle(path, h) 156 ts := httptest.NewServer(mux) 157 158 rpcClient := filesystemconnect.NewFilesystemClient(ts.Client(), ts.URL) 159 domain := "test.dev" 160 c := &Client{config: &Config{}} 161 sb := &Sandbox{sandboxID: "sb-test", domain: &domain, client: c} 162 fs := &Filesystem{sandbox: sb, rpc: rpcClient} 163 return fs, ts 164 } 165 166 // newTestCommands starts a ConnectRPC mock server and returns Commands + server. 167 func newTestCommands(handler *testProcessHandler) (*Commands, *httptest.Server) { 168 mux := http.NewServeMux() 169 path, h := processconnect.NewProcessHandler(handler) 170 mux.Handle(path, h) 171 ts := httptest.NewServer(mux) 172 173 rpcClient := processconnect.NewProcessClient(ts.Client(), ts.URL) 174 domain := "test.dev" 175 c := &Client{config: &Config{}} 176 sb := &Sandbox{sandboxID: "sb-test", domain: &domain, client: c} 177 cmd := &Commands{sandbox: sb, rpc: rpcClient} 178 return cmd, ts 179 } 180 181 // newTestPty starts a ConnectRPC mock server and returns Pty + server. 182 func newTestPty(handler *testProcessHandler) (*Pty, *httptest.Server) { 183 mux := http.NewServeMux() 184 path, h := processconnect.NewProcessHandler(handler) 185 mux.Handle(path, h) 186 ts := httptest.NewServer(mux) 187 188 rpcClient := processconnect.NewProcessClient(ts.Client(), ts.URL) 189 domain := "test.dev" 190 c := &Client{config: &Config{}} 191 sb := &Sandbox{sandboxID: "sb-test", domain: &domain, client: c} 192 p := &Pty{sandbox: sb, rpc: rpcClient} 193 return p, ts 194 } 195 196 // newTestSandboxWithHTTP creates a Sandbox whose HTTPClient routes to the given 197 // test server. Useful for Filesystem.Read/Write tests. 198 func newTestSandboxWithHTTP(ts *httptest.Server) *Sandbox { 199 domain := "test.dev" 200 c := &Client{config: &Config{HTTPClient: ts.Client()}} 201 sb := &Sandbox{sandboxID: "sb-test", domain: &domain, client: c} 202 return sb 203 } 204 205 // ========================================================================= 206 // 1. Filesystem RPC tests 207 // ========================================================================= 208 209 func TestFilesystemList(t *testing.T) { 210 now := timestamppb.Now() 211 handler := &testFilesystemHandler{ 212 listDirFn: func(_ context.Context, req *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) { 213 if req.Msg.Path != "/home/user" { 214 t.Errorf("ListDir path = %q, want %q", req.Msg.Path, "/home/user") 215 } 216 if req.Msg.Depth != 2 { 217 t.Errorf("ListDir depth = %d, want 2", req.Msg.Depth) 218 } 219 return connect.NewResponse(&filesystem.ListDirResponse{ 220 Entries: []*filesystem.EntryInfo{ 221 {Name: "a.txt", Type: filesystem.FileType_FILE_TYPE_FILE, Path: "/home/user/a.txt", Size: 100, ModifiedTime: now}, 222 {Name: "sub", Type: filesystem.FileType_FILE_TYPE_DIRECTORY, Path: "/home/user/sub", ModifiedTime: now}, 223 }, 224 }), nil 225 }, 226 } 227 fs, ts := newTestFilesystem(handler) 228 defer ts.Close() 229 230 entries, err := fs.List(context.Background(), "/home/user", WithDepth(2)) 231 if err != nil { 232 t.Fatalf("List error: %v", err) 233 } 234 if len(entries) != 2 { 235 t.Fatalf("List returned %d entries, want 2", len(entries)) 236 } 237 if entries[0].Name != "a.txt" || entries[0].Type != FileTypeFile { 238 t.Errorf("entries[0] = %+v, want name=a.txt type=file", entries[0]) 239 } 240 if entries[1].Name != "sub" || entries[1].Type != FileTypeDirectory { 241 t.Errorf("entries[1] = %+v, want name=sub type=dir", entries[1]) 242 } 243 } 244 245 func TestFilesystemGetInfo(t *testing.T) { 246 now := timestamppb.New(time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)) 247 symTarget := "/real/path" 248 handler := &testFilesystemHandler{ 249 statFn: func(_ context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { 250 if req.Msg.Path != "/tmp/test.txt" { 251 t.Errorf("Stat path = %q, want %q", req.Msg.Path, "/tmp/test.txt") 252 } 253 return connect.NewResponse(&filesystem.StatResponse{ 254 Entry: &filesystem.EntryInfo{ 255 Name: "test.txt", 256 Type: filesystem.FileType_FILE_TYPE_FILE, 257 Path: "/tmp/test.txt", 258 Size: 256, 259 Mode: 0o644, 260 Permissions: "rw-r--r--", 261 Owner: "user", 262 Group: "user", 263 ModifiedTime: now, 264 SymlinkTarget: &symTarget, 265 }, 266 }), nil 267 }, 268 } 269 fs, ts := newTestFilesystem(handler) 270 defer ts.Close() 271 272 info, err := fs.GetInfo(context.Background(), "/tmp/test.txt") 273 if err != nil { 274 t.Fatalf("GetInfo error: %v", err) 275 } 276 if info.Name != "test.txt" { 277 t.Errorf("Name = %q, want %q", info.Name, "test.txt") 278 } 279 if info.Type != FileTypeFile { 280 t.Errorf("Type = %q, want %q", info.Type, FileTypeFile) 281 } 282 if info.Size != 256 { 283 t.Errorf("Size = %d, want 256", info.Size) 284 } 285 if info.SymlinkTarget == nil || *info.SymlinkTarget != "/real/path" { 286 t.Errorf("SymlinkTarget = %v, want /real/path", info.SymlinkTarget) 287 } 288 wantTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) 289 if !info.ModifiedTime.Equal(wantTime) { 290 t.Errorf("ModifiedTime = %v, want %v", info.ModifiedTime, wantTime) 291 } 292 } 293 294 func TestFilesystemMakeDir(t *testing.T) { 295 handler := &testFilesystemHandler{ 296 makeDirFn: func(_ context.Context, req *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) { 297 if req.Msg.Path != "/home/user/newdir" { 298 t.Errorf("MakeDir path = %q, want %q", req.Msg.Path, "/home/user/newdir") 299 } 300 return connect.NewResponse(&filesystem.MakeDirResponse{ 301 Entry: &filesystem.EntryInfo{ 302 Name: "newdir", 303 Type: filesystem.FileType_FILE_TYPE_DIRECTORY, 304 Path: "/home/user/newdir", 305 }, 306 }), nil 307 }, 308 } 309 fs, ts := newTestFilesystem(handler) 310 defer ts.Close() 311 312 info, err := fs.MakeDir(context.Background(), "/home/user/newdir") 313 if err != nil { 314 t.Fatalf("MakeDir error: %v", err) 315 } 316 if info.Name != "newdir" || info.Type != FileTypeDirectory { 317 t.Errorf("MakeDir result = %+v, want name=newdir type=dir", info) 318 } 319 } 320 321 func TestFilesystemRemove(t *testing.T) { 322 handler := &testFilesystemHandler{ 323 removeFn: func(_ context.Context, req *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) { 324 if req.Msg.Path != "/tmp/old.txt" { 325 t.Errorf("Remove path = %q, want %q", req.Msg.Path, "/tmp/old.txt") 326 } 327 return connect.NewResponse(&filesystem.RemoveResponse{}), nil 328 }, 329 } 330 fs, ts := newTestFilesystem(handler) 331 defer ts.Close() 332 333 if err := fs.Remove(context.Background(), "/tmp/old.txt"); err != nil { 334 t.Fatalf("Remove error: %v", err) 335 } 336 } 337 338 func TestFilesystemRename(t *testing.T) { 339 handler := &testFilesystemHandler{ 340 moveFn: func(_ context.Context, req *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) { 341 if req.Msg.Source != "/a.txt" || req.Msg.Destination != "/b.txt" { 342 t.Errorf("Move src=%q dst=%q, want src=/a.txt dst=/b.txt", req.Msg.Source, req.Msg.Destination) 343 } 344 return connect.NewResponse(&filesystem.MoveResponse{ 345 Entry: &filesystem.EntryInfo{ 346 Name: "b.txt", 347 Type: filesystem.FileType_FILE_TYPE_FILE, 348 Path: "/b.txt", 349 }, 350 }), nil 351 }, 352 } 353 fs, ts := newTestFilesystem(handler) 354 defer ts.Close() 355 356 info, err := fs.Rename(context.Background(), "/a.txt", "/b.txt") 357 if err != nil { 358 t.Fatalf("Rename error: %v", err) 359 } 360 if info.Name != "b.txt" || info.Path != "/b.txt" { 361 t.Errorf("Rename result = %+v", info) 362 } 363 } 364 365 func TestFilesystemExists(t *testing.T) { 366 handler := &testFilesystemHandler{ 367 statFn: func(_ context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { 368 if req.Msg.Path == "/exists" { 369 return connect.NewResponse(&filesystem.StatResponse{ 370 Entry: &filesystem.EntryInfo{Name: "exists", Path: "/exists"}, 371 }), nil 372 } 373 return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("not found")) 374 }, 375 } 376 fs, ts := newTestFilesystem(handler) 377 defer ts.Close() 378 379 ok, err := fs.Exists(context.Background(), "/exists") 380 if err != nil { 381 t.Fatalf("Exists error: %v", err) 382 } 383 if !ok { 384 t.Error("Exists(/exists) = false, want true") 385 } 386 387 ok, err = fs.Exists(context.Background(), "/missing") 388 if err != nil { 389 t.Fatalf("Exists error: %v", err) 390 } 391 if ok { 392 t.Error("Exists(/missing) = true, want false") 393 } 394 } 395 396 func TestFilesystemWatchDir(t *testing.T) { 397 handler := &testFilesystemHandler{ 398 watchDirFn: func(_ context.Context, req *connect.Request[filesystem.WatchDirRequest], stream *connect.ServerStream[filesystem.WatchDirResponse]) error { 399 if req.Msg.Path != "/watch" { 400 t.Errorf("WatchDir path = %q, want %q", req.Msg.Path, "/watch") 401 } 402 if !req.Msg.Recursive { 403 t.Error("WatchDir recursive = false, want true") 404 } 405 // 发送 start 事件 406 if err := stream.Send(&filesystem.WatchDirResponse{ 407 Event: &filesystem.WatchDirResponse_Start{ 408 Start: &filesystem.WatchDirResponse_StartEvent{}, 409 }, 410 }); err != nil { 411 return err 412 } 413 // 发送文件系统事件 414 events := []struct { 415 name string 416 typ filesystem.EventType 417 }{ 418 {"created.txt", filesystem.EventType_EVENT_TYPE_CREATE}, 419 {"modified.txt", filesystem.EventType_EVENT_TYPE_WRITE}, 420 {"removed.txt", filesystem.EventType_EVENT_TYPE_REMOVE}, 421 } 422 for _, e := range events { 423 if err := stream.Send(&filesystem.WatchDirResponse{ 424 Event: &filesystem.WatchDirResponse_Filesystem{ 425 Filesystem: &filesystem.FilesystemEvent{ 426 Name: e.name, 427 Type: e.typ, 428 }, 429 }, 430 }); err != nil { 431 return err 432 } 433 } 434 return nil 435 }, 436 } 437 fs, ts := newTestFilesystem(handler) 438 defer ts.Close() 439 440 w, err := fs.WatchDir(context.Background(), "/watch", WithRecursive(true)) 441 if err != nil { 442 t.Fatalf("WatchDir error: %v", err) 443 } 444 445 var received []FilesystemEvent 446 for ev := range w.Events() { 447 received = append(received, ev) 448 } 449 450 if len(received) != 3 { 451 t.Fatalf("received %d events, want 3", len(received)) 452 } 453 454 wantEvents := []struct { 455 name string 456 typ EventType 457 }{ 458 {"created.txt", EventCreate}, 459 {"modified.txt", EventWrite}, 460 {"removed.txt", EventRemove}, 461 } 462 for i, want := range wantEvents { 463 if received[i].Name != want.name || received[i].Type != want.typ { 464 t.Errorf("event[%d] = %+v, want name=%q type=%q", i, received[i], want.name, want.typ) 465 } 466 } 467 468 if err := w.Err(); err != nil { 469 t.Errorf("WatchHandle.Err() = %v, want nil", err) 470 } 471 } 472 473 // ========================================================================= 474 // 2. Filesystem HTTP (Read / Write) tests 475 // ========================================================================= 476 477 func TestFilesystemRead(t *testing.T) { 478 fileContent := []byte("hello world from sandbox") 479 httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 480 if r.Method != http.MethodGet { 481 t.Errorf("method = %q, want GET", r.Method) 482 } 483 w.WriteHeader(http.StatusOK) 484 w.Write(fileContent) 485 })) 486 defer httpServer.Close() 487 488 sb := newTestSandboxWithHTTP(httpServer) 489 // 创建 Filesystem 并注入 sandbox 使其使用 mock HTTPClient 490 fs := &Filesystem{sandbox: sb} 491 492 // 由于 Filesystem.Read 通过 sb.DownloadURL 构造 URL,其域名不匹配 test server, 493 // 需要让 HTTPClient 的 transport 将请求路由到 test server。 494 sb.client.config.HTTPClient = httpServer.Client() 495 // 覆盖 sandbox 的 DownloadURL 使其指向 test server 496 // 使用自定义 transport 将所有请求重定向到 test server 497 sb.client.config.HTTPClient.Transport = &rewriteTransport{ 498 base: httpServer.Client().Transport, 499 baseURL: httpServer.URL, 500 } 501 502 data, err := fs.Read(context.Background(), "/test/file.txt") 503 if err != nil { 504 t.Fatalf("Read error: %v", err) 505 } 506 if string(data) != string(fileContent) { 507 t.Errorf("Read = %q, want %q", string(data), string(fileContent)) 508 } 509 } 510 511 func TestFilesystemReadText(t *testing.T) { 512 httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 513 w.WriteHeader(http.StatusOK) 514 fmt.Fprint(w, "text content") 515 })) 516 defer httpServer.Close() 517 518 sb := newTestSandboxWithHTTP(httpServer) 519 sb.client.config.HTTPClient.Transport = &rewriteTransport{ 520 base: httpServer.Client().Transport, 521 baseURL: httpServer.URL, 522 } 523 fs := &Filesystem{sandbox: sb} 524 525 text, err := fs.ReadText(context.Background(), "/readme.md") 526 if err != nil { 527 t.Fatalf("ReadText error: %v", err) 528 } 529 if text != "text content" { 530 t.Errorf("ReadText = %q, want %q", text, "text content") 531 } 532 } 533 534 func TestFilesystemReadError(t *testing.T) { 535 httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 536 w.WriteHeader(http.StatusNotFound) 537 w.Write([]byte("file not found")) 538 })) 539 defer httpServer.Close() 540 541 sb := newTestSandboxWithHTTP(httpServer) 542 sb.client.config.HTTPClient.Transport = &rewriteTransport{ 543 base: httpServer.Client().Transport, 544 baseURL: httpServer.URL, 545 } 546 fs := &Filesystem{sandbox: sb} 547 548 _, err := fs.Read(context.Background(), "/missing.txt") 549 if err == nil { 550 t.Fatal("Read expected error for 404, got nil") 551 } 552 apiErr, ok := err.(*APIError) 553 if !ok { 554 t.Fatalf("error type = %T, want *APIError", err) 555 } 556 if apiErr.StatusCode != http.StatusNotFound { 557 t.Errorf("StatusCode = %d, want 404", apiErr.StatusCode) 558 } 559 } 560 561 func TestFilesystemWrite(t *testing.T) { 562 var uploadedBody []byte 563 // mux 处理 HTTP 上传和 RPC Stat 564 mux := http.NewServeMux() 565 566 // HTTP 文件上传端点 567 mux.HandleFunc("/files", func(w http.ResponseWriter, r *http.Request) { 568 if r.Method != http.MethodPost { 569 t.Errorf("method = %q, want POST", r.Method) 570 } 571 body, _ := io.ReadAll(r.Body) 572 uploadedBody = body 573 w.WriteHeader(http.StatusOK) 574 }) 575 576 // ConnectRPC Stat 端点(Write 成功后会调用 GetInfo) 577 fsHandler := &testFilesystemHandler{ 578 statFn: func(_ context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { 579 return connect.NewResponse(&filesystem.StatResponse{ 580 Entry: &filesystem.EntryInfo{ 581 Name: "data.bin", 582 Type: filesystem.FileType_FILE_TYPE_FILE, 583 Path: req.Msg.Path, 584 Size: 5, 585 }, 586 }), nil 587 }, 588 } 589 rpcPath, rpcHandler := filesystemconnect.NewFilesystemHandler(fsHandler) 590 mux.Handle(rpcPath, rpcHandler) 591 592 ts := httptest.NewServer(mux) 593 defer ts.Close() 594 595 sb := newTestSandboxWithHTTP(ts) 596 sb.client.config.HTTPClient.Transport = &rewriteTransport{ 597 base: ts.Client().Transport, 598 baseURL: ts.URL, 599 } 600 rpcClient := filesystemconnect.NewFilesystemClient(ts.Client(), ts.URL) 601 fs := &Filesystem{sandbox: sb, rpc: rpcClient} 602 603 info, err := fs.Write(context.Background(), "/data.bin", []byte("hello")) 604 if err != nil { 605 t.Fatalf("Write error: %v", err) 606 } 607 if info == nil { 608 t.Fatal("Write returned nil info") 609 } 610 if info.Name != "data.bin" || info.Size != 5 { 611 t.Errorf("Write info = %+v", info) 612 } 613 if len(uploadedBody) == 0 { 614 t.Error("upload body was empty") 615 } 616 } 617 618 func TestFilesystemWriteFiles(t *testing.T) { 619 uploadCount := 0 620 mux := http.NewServeMux() 621 mux.HandleFunc("/files", func(w http.ResponseWriter, r *http.Request) { 622 if r.Method == http.MethodPost { 623 uploadCount++ 624 io.ReadAll(r.Body) 625 w.WriteHeader(http.StatusOK) 626 return 627 } 628 http.NotFound(w, r) 629 }) 630 631 statCalls := 0 632 fsHandler := &testFilesystemHandler{ 633 statFn: func(_ context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { 634 statCalls++ 635 return connect.NewResponse(&filesystem.StatResponse{ 636 Entry: &filesystem.EntryInfo{ 637 Name: "file", 638 Type: filesystem.FileType_FILE_TYPE_FILE, 639 Path: req.Msg.Path, 640 Size: 3, 641 }, 642 }), nil 643 }, 644 } 645 rpcPath, rpcHandler := filesystemconnect.NewFilesystemHandler(fsHandler) 646 mux.Handle(rpcPath, rpcHandler) 647 648 ts := httptest.NewServer(mux) 649 defer ts.Close() 650 651 sb := newTestSandboxWithHTTP(ts) 652 sb.client.config.HTTPClient.Transport = &rewriteTransport{ 653 base: ts.Client().Transport, 654 baseURL: ts.URL, 655 } 656 rpcClient := filesystemconnect.NewFilesystemClient(ts.Client(), ts.URL) 657 fs := &Filesystem{sandbox: sb, rpc: rpcClient} 658 659 files := []WriteEntry{ 660 {Path: "/a.txt", Data: []byte("aaa")}, 661 {Path: "/b.txt", Data: []byte("bbb")}, 662 {Path: "/c.txt", Data: []byte("ccc")}, 663 } 664 infos, err := fs.WriteFiles(context.Background(), files) 665 if err != nil { 666 t.Fatalf("WriteFiles error: %v", err) 667 } 668 if len(infos) != 3 { 669 t.Fatalf("WriteFiles returned %d infos, want 3", len(infos)) 670 } 671 // 多文件使用单次 batch upload 672 if uploadCount != 1 { 673 t.Errorf("upload count = %d, want 1", uploadCount) 674 } 675 // 每个文件调用 GetInfo 676 if statCalls != 3 { 677 t.Errorf("stat calls = %d, want 3", statCalls) 678 } 679 } 680 681 // ========================================================================= 682 // 3. Commands tests 683 // ========================================================================= 684 685 func TestCommandsRun(t *testing.T) { 686 handler := &testProcessHandler{ 687 startFn: func(_ context.Context, req *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 688 // Start 事件 689 if err := stream.Send(&process.StartResponse{ 690 Event: &process.ProcessEvent{ 691 Event: &process.ProcessEvent_Start{ 692 Start: &process.ProcessEvent_StartEvent{Pid: 42}, 693 }, 694 }, 695 }); err != nil { 696 return err 697 } 698 // Data 事件 (stdout) 699 if err := stream.Send(&process.StartResponse{ 700 Event: &process.ProcessEvent{ 701 Event: &process.ProcessEvent_Data{ 702 Data: &process.ProcessEvent_DataEvent{ 703 Output: &process.ProcessEvent_DataEvent_Stdout{ 704 Stdout: []byte("hello\n"), 705 }, 706 }, 707 }, 708 }, 709 }); err != nil { 710 return err 711 } 712 // Data 事件 (stderr) 713 if err := stream.Send(&process.StartResponse{ 714 Event: &process.ProcessEvent{ 715 Event: &process.ProcessEvent_Data{ 716 Data: &process.ProcessEvent_DataEvent{ 717 Output: &process.ProcessEvent_DataEvent_Stderr{ 718 Stderr: []byte("warn\n"), 719 }, 720 }, 721 }, 722 }, 723 }); err != nil { 724 return err 725 } 726 // End 事件 727 return stream.Send(&process.StartResponse{ 728 Event: &process.ProcessEvent{ 729 Event: &process.ProcessEvent_End{ 730 End: &process.ProcessEvent_EndEvent{ 731 ExitCode: 0, 732 Exited: true, 733 Status: "exited", 734 }, 735 }, 736 }, 737 }) 738 }, 739 } 740 cmd, ts := newTestCommands(handler) 741 defer ts.Close() 742 743 result, err := cmd.Run(context.Background(), "echo hello") 744 if err != nil { 745 t.Fatalf("Run error: %v", err) 746 } 747 if result.ExitCode != 0 { 748 t.Errorf("ExitCode = %d, want 0", result.ExitCode) 749 } 750 if result.Stdout != "hello\n" { 751 t.Errorf("Stdout = %q, want %q", result.Stdout, "hello\n") 752 } 753 if result.Stderr != "warn\n" { 754 t.Errorf("Stderr = %q, want %q", result.Stderr, "warn\n") 755 } 756 } 757 758 func TestCommandsStart(t *testing.T) { 759 handler := &testProcessHandler{ 760 startFn: func(_ context.Context, _ *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 761 stream.Send(&process.StartResponse{ 762 Event: &process.ProcessEvent{ 763 Event: &process.ProcessEvent_Start{ 764 Start: &process.ProcessEvent_StartEvent{Pid: 100}, 765 }, 766 }, 767 }) 768 stream.Send(&process.StartResponse{ 769 Event: &process.ProcessEvent{ 770 Event: &process.ProcessEvent_End{ 771 End: &process.ProcessEvent_EndEvent{ExitCode: 1, Status: "exited"}, 772 }, 773 }, 774 }) 775 return nil 776 }, 777 } 778 cmd, ts := newTestCommands(handler) 779 defer ts.Close() 780 781 handle, err := cmd.Start(context.Background(), "false") 782 if err != nil { 783 t.Fatalf("Start error: %v", err) 784 } 785 786 result, err := handle.Wait() 787 if err != nil { 788 t.Fatalf("Wait error: %v", err) 789 } 790 if result.ExitCode != 1 { 791 t.Errorf("ExitCode = %d, want 1", result.ExitCode) 792 } 793 if handle.PID() != 100 { 794 t.Errorf("PID = %d, want 100", handle.PID()) 795 } 796 } 797 798 func TestCommandsRunWithCallbacks(t *testing.T) { 799 handler := &testProcessHandler{ 800 startFn: func(_ context.Context, _ *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 801 stream.Send(&process.StartResponse{ 802 Event: &process.ProcessEvent{ 803 Event: &process.ProcessEvent_Start{ 804 Start: &process.ProcessEvent_StartEvent{Pid: 50}, 805 }, 806 }, 807 }) 808 stream.Send(&process.StartResponse{ 809 Event: &process.ProcessEvent{ 810 Event: &process.ProcessEvent_Data{ 811 Data: &process.ProcessEvent_DataEvent{ 812 Output: &process.ProcessEvent_DataEvent_Stdout{ 813 Stdout: []byte("out1"), 814 }, 815 }, 816 }, 817 }, 818 }) 819 stream.Send(&process.StartResponse{ 820 Event: &process.ProcessEvent{ 821 Event: &process.ProcessEvent_Data{ 822 Data: &process.ProcessEvent_DataEvent{ 823 Output: &process.ProcessEvent_DataEvent_Stderr{ 824 Stderr: []byte("err1"), 825 }, 826 }, 827 }, 828 }, 829 }) 830 stream.Send(&process.StartResponse{ 831 Event: &process.ProcessEvent{ 832 Event: &process.ProcessEvent_End{ 833 End: &process.ProcessEvent_EndEvent{ExitCode: 0}, 834 }, 835 }, 836 }) 837 return nil 838 }, 839 } 840 cmd, ts := newTestCommands(handler) 841 defer ts.Close() 842 843 var mu sync.Mutex 844 var stdoutParts, stderrParts []string 845 846 result, err := cmd.Run(context.Background(), "test", 847 WithOnStdout(func(data []byte) { 848 mu.Lock() 849 stdoutParts = append(stdoutParts, string(data)) 850 mu.Unlock() 851 }), 852 WithOnStderr(func(data []byte) { 853 mu.Lock() 854 stderrParts = append(stderrParts, string(data)) 855 mu.Unlock() 856 }), 857 ) 858 if err != nil { 859 t.Fatalf("Run error: %v", err) 860 } 861 if result.ExitCode != 0 { 862 t.Errorf("ExitCode = %d, want 0", result.ExitCode) 863 } 864 865 mu.Lock() 866 defer mu.Unlock() 867 if len(stdoutParts) != 1 || stdoutParts[0] != "out1" { 868 t.Errorf("stdout callback = %v, want [out1]", stdoutParts) 869 } 870 if len(stderrParts) != 1 || stderrParts[0] != "err1" { 871 t.Errorf("stderr callback = %v, want [err1]", stderrParts) 872 } 873 } 874 875 func TestCommandsList(t *testing.T) { 876 tag := "mytag" 877 cwd := "/home/user" 878 handler := &testProcessHandler{ 879 listFn: func(_ context.Context, _ *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) { 880 return connect.NewResponse(&process.ListResponse{ 881 Processes: []*process.ProcessInfo{ 882 { 883 Pid: 1, 884 Tag: &tag, 885 Config: &process.ProcessConfig{ 886 Cmd: "/bin/bash", 887 Args: []string{"-c", "sleep 100"}, 888 Envs: map[string]string{"A": "B"}, 889 Cwd: &cwd, 890 }, 891 }, 892 { 893 Pid: 2, 894 }, 895 }, 896 }), nil 897 }, 898 } 899 cmd, ts := newTestCommands(handler) 900 defer ts.Close() 901 902 infos, err := cmd.List(context.Background()) 903 if err != nil { 904 t.Fatalf("List error: %v", err) 905 } 906 if len(infos) != 2 { 907 t.Fatalf("List returned %d, want 2", len(infos)) 908 } 909 910 if infos[0].PID != 1 { 911 t.Errorf("PID = %d, want 1", infos[0].PID) 912 } 913 if infos[0].Tag == nil || *infos[0].Tag != "mytag" { 914 t.Errorf("Tag = %v, want mytag", infos[0].Tag) 915 } 916 if infos[0].Cmd != "/bin/bash" { 917 t.Errorf("Cmd = %q, want /bin/bash", infos[0].Cmd) 918 } 919 if infos[0].Cwd == nil || *infos[0].Cwd != "/home/user" { 920 t.Errorf("Cwd = %v, want /home/user", infos[0].Cwd) 921 } 922 if infos[0].Envs["A"] != "B" { 923 t.Errorf("Envs = %v, want A=B", infos[0].Envs) 924 } 925 926 // 第二个进程没有 config 927 if infos[1].PID != 2 { 928 t.Errorf("PID = %d, want 2", infos[1].PID) 929 } 930 if infos[1].Cmd != "" { 931 t.Errorf("Cmd = %q, want empty", infos[1].Cmd) 932 } 933 } 934 935 func TestCommandsSendStdin(t *testing.T) { 936 var receivedPID uint32 937 var receivedData []byte 938 handler := &testProcessHandler{ 939 sendInputFn: func(_ context.Context, req *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { 940 if sel, ok := req.Msg.Process.Selector.(*process.ProcessSelector_Pid); ok { 941 receivedPID = sel.Pid 942 } 943 if stdin := req.Msg.Input.GetStdin(); len(stdin) > 0 { 944 receivedData = stdin 945 } 946 return connect.NewResponse(&process.SendInputResponse{}), nil 947 }, 948 } 949 cmd, ts := newTestCommands(handler) 950 defer ts.Close() 951 952 err := cmd.SendStdin(context.Background(), 77, []byte("input data")) 953 if err != nil { 954 t.Fatalf("SendStdin error: %v", err) 955 } 956 if receivedPID != 77 { 957 t.Errorf("PID = %d, want 77", receivedPID) 958 } 959 if string(receivedData) != "input data" { 960 t.Errorf("data = %q, want %q", string(receivedData), "input data") 961 } 962 } 963 964 func TestCommandsCloseStdin(t *testing.T) { 965 var receivedPID uint32 966 handler := &testProcessHandler{ 967 closeStdinFn: func(_ context.Context, req *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { 968 if sel, ok := req.Msg.Process.Selector.(*process.ProcessSelector_Pid); ok { 969 receivedPID = sel.Pid 970 } 971 return connect.NewResponse(&process.CloseStdinResponse{}), nil 972 }, 973 } 974 cmd, ts := newTestCommands(handler) 975 defer ts.Close() 976 977 err := cmd.CloseStdin(context.Background(), 88) 978 if err != nil { 979 t.Fatalf("CloseStdin error: %v", err) 980 } 981 if receivedPID != 88 { 982 t.Errorf("PID = %d, want 88", receivedPID) 983 } 984 } 985 986 func TestCommandsKill(t *testing.T) { 987 var receivedPID uint32 988 var receivedSignal process.Signal 989 handler := &testProcessHandler{ 990 sendSignalFn: func(_ context.Context, req *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { 991 if sel, ok := req.Msg.Process.Selector.(*process.ProcessSelector_Pid); ok { 992 receivedPID = sel.Pid 993 } 994 receivedSignal = req.Msg.Signal 995 return connect.NewResponse(&process.SendSignalResponse{}), nil 996 }, 997 } 998 cmd, ts := newTestCommands(handler) 999 defer ts.Close() 1000 1001 err := cmd.Kill(context.Background(), 99) 1002 if err != nil { 1003 t.Fatalf("Kill error: %v", err) 1004 } 1005 if receivedPID != 99 { 1006 t.Errorf("PID = %d, want 99", receivedPID) 1007 } 1008 if receivedSignal != process.Signal_SIGNAL_SIGKILL { 1009 t.Errorf("Signal = %v, want SIGKILL", receivedSignal) 1010 } 1011 } 1012 1013 func TestCommandsRunWithError(t *testing.T) { 1014 errMsg := "command failed" 1015 handler := &testProcessHandler{ 1016 startFn: func(_ context.Context, _ *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 1017 stream.Send(&process.StartResponse{ 1018 Event: &process.ProcessEvent{ 1019 Event: &process.ProcessEvent_Start{ 1020 Start: &process.ProcessEvent_StartEvent{Pid: 10}, 1021 }, 1022 }, 1023 }) 1024 stream.Send(&process.StartResponse{ 1025 Event: &process.ProcessEvent{ 1026 Event: &process.ProcessEvent_End{ 1027 End: &process.ProcessEvent_EndEvent{ 1028 ExitCode: 127, 1029 Error: &errMsg, 1030 }, 1031 }, 1032 }, 1033 }) 1034 return nil 1035 }, 1036 } 1037 cmd, ts := newTestCommands(handler) 1038 defer ts.Close() 1039 1040 result, err := cmd.Run(context.Background(), "nonexistent") 1041 if err != nil { 1042 t.Fatalf("Run error: %v", err) 1043 } 1044 if result.ExitCode != 127 { 1045 t.Errorf("ExitCode = %d, want 127", result.ExitCode) 1046 } 1047 if result.Error != "command failed" { 1048 t.Errorf("Error = %q, want %q", result.Error, "command failed") 1049 } 1050 } 1051 1052 // TestCommandsConnectWithStartEvent 回归测试 issue #215: 1053 // Commands.Connect 路径下若服务端流仍下发 Start 事件, 1054 // 共享的 processEventStream 不应再次 close 已关闭的 pidCh 而 panic。 1055 // 1056 // 同时锁定 PID 优先级语义:Connect 由调用方传入 PID, 1057 // 即便服务端 Start 事件携带不同 PID,也应以调用方传入的为准 1058 // (由 markPIDReady 内部的 sync.Once 保证首次写入获胜)。 1059 func TestCommandsConnectWithStartEvent(t *testing.T) { 1060 tests := []struct { 1061 name string 1062 callerPID uint32 1063 serverStartPID uint32 1064 }{ 1065 { 1066 name: "matching pid", 1067 callerPID: 4242, 1068 serverStartPID: 4242, 1069 }, 1070 { 1071 // 守护 PR 描述里声明的契约:调用方传入的 PID 获胜, 1072 // 避免未来重构在 markPIDReady 中改成"后写入覆盖前者"而无测试报警。 1073 name: "server start pid mismatches caller pid", 1074 callerPID: 4242, 1075 serverStartPID: 9999, 1076 }, 1077 } 1078 1079 for _, tc := range tests { 1080 tc := tc 1081 t.Run(tc.name, func(t *testing.T) { 1082 handler := &testProcessHandler{ 1083 connectFn: func(_ context.Context, req *connect.Request[process.ConnectRequest], stream *connect.ServerStream[process.ConnectResponse]) error { 1084 // 校验请求里携带的是按 PID 选择 1085 if got := req.Msg.GetProcess().GetPid(); got != tc.callerPID { 1086 t.Errorf("Connect pid selector = %d, want %d", got, tc.callerPID) 1087 } 1088 // 服务端先发 Start 事件,再发 End — 触发 panic 的最小复现 1089 if err := stream.Send(&process.ConnectResponse{ 1090 Event: &process.ProcessEvent{ 1091 Event: &process.ProcessEvent_Start{ 1092 Start: &process.ProcessEvent_StartEvent{Pid: tc.serverStartPID}, 1093 }, 1094 }, 1095 }); err != nil { 1096 return err 1097 } 1098 return stream.Send(&process.ConnectResponse{ 1099 Event: &process.ProcessEvent{ 1100 Event: &process.ProcessEvent_End{ 1101 End: &process.ProcessEvent_EndEvent{ExitCode: 0, Status: "exited"}, 1102 }, 1103 }, 1104 }) 1105 }, 1106 } 1107 cmd, ts := newTestCommands(handler) 1108 defer ts.Close() 1109 1110 handle, err := cmd.Connect(context.Background(), tc.callerPID) 1111 if err != nil { 1112 t.Fatalf("Connect error: %v", err) 1113 } 1114 1115 pid, err := handle.WaitPID(context.Background()) 1116 if err != nil { 1117 t.Fatalf("WaitPID error: %v", err) 1118 } 1119 if pid != tc.callerPID { 1120 t.Errorf("WaitPID = %d, want caller pid %d", pid, tc.callerPID) 1121 } 1122 1123 result, err := handle.Wait() 1124 if err != nil { 1125 t.Fatalf("Wait error: %v", err) 1126 } 1127 if result.ExitCode != 0 { 1128 t.Errorf("ExitCode = %d, want 0", result.ExitCode) 1129 } 1130 if handle.PID() != tc.callerPID { 1131 t.Errorf("PID() = %d, want caller pid %d", handle.PID(), tc.callerPID) 1132 } 1133 }) 1134 } 1135 } 1136 1137 // ========================================================================= 1138 // 4. PTY tests 1139 // ========================================================================= 1140 1141 func TestPtyCreate(t *testing.T) { 1142 handler := &testProcessHandler{ 1143 startFn: func(_ context.Context, req *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 1144 // 验证 PTY 配置 1145 if req.Msg.Pty == nil || req.Msg.Pty.Size == nil { 1146 t.Error("expected PTY size in request") 1147 return nil 1148 } 1149 if req.Msg.Pty.Size.Cols != 120 || req.Msg.Pty.Size.Rows != 40 { 1150 t.Errorf("PTY size = %dx%d, want 120x40", req.Msg.Pty.Size.Cols, req.Msg.Pty.Size.Rows) 1151 } 1152 // 验证 bash -i -l 命令 1153 if req.Msg.Process.Cmd != "/bin/bash" { 1154 t.Errorf("Cmd = %q, want /bin/bash", req.Msg.Process.Cmd) 1155 } 1156 if len(req.Msg.Process.Args) < 2 || req.Msg.Process.Args[0] != "-i" || req.Msg.Process.Args[1] != "-l" { 1157 t.Errorf("Args = %v, want [-i -l]", req.Msg.Process.Args) 1158 } 1159 // 验证默认环境变量 1160 if req.Msg.Process.Envs["TERM"] != "xterm" { 1161 t.Errorf("TERM = %q, want xterm", req.Msg.Process.Envs["TERM"]) 1162 } 1163 1164 // Start 事件 1165 stream.Send(&process.StartResponse{ 1166 Event: &process.ProcessEvent{ 1167 Event: &process.ProcessEvent_Start{ 1168 Start: &process.ProcessEvent_StartEvent{Pid: 200}, 1169 }, 1170 }, 1171 }) 1172 // PTY data 事件 1173 stream.Send(&process.StartResponse{ 1174 Event: &process.ProcessEvent{ 1175 Event: &process.ProcessEvent_Data{ 1176 Data: &process.ProcessEvent_DataEvent{ 1177 Output: &process.ProcessEvent_DataEvent_Pty{ 1178 Pty: []byte("bash$ "), 1179 }, 1180 }, 1181 }, 1182 }, 1183 }) 1184 // End 事件 1185 stream.Send(&process.StartResponse{ 1186 Event: &process.ProcessEvent{ 1187 Event: &process.ProcessEvent_End{ 1188 End: &process.ProcessEvent_EndEvent{ExitCode: 0}, 1189 }, 1190 }, 1191 }) 1192 return nil 1193 }, 1194 } 1195 pty, ts := newTestPty(handler) 1196 defer ts.Close() 1197 1198 var ptyOutput []byte 1199 var mu sync.Mutex 1200 handle, err := pty.Create(context.Background(), PtySize{Cols: 120, Rows: 40}, 1201 WithOnStdout(func(data []byte) { 1202 mu.Lock() 1203 ptyOutput = append(ptyOutput, data...) 1204 mu.Unlock() 1205 }), 1206 ) 1207 if err != nil { 1208 t.Fatalf("Create error: %v", err) 1209 } 1210 1211 result, err := handle.Wait() 1212 if err != nil { 1213 t.Fatalf("Wait error: %v", err) 1214 } 1215 if result.ExitCode != 0 { 1216 t.Errorf("ExitCode = %d, want 0", result.ExitCode) 1217 } 1218 1219 mu.Lock() 1220 defer mu.Unlock() 1221 if string(ptyOutput) != "bash$ " { 1222 t.Errorf("pty output = %q, want %q", string(ptyOutput), "bash$ ") 1223 } 1224 } 1225 1226 func TestPtySendInput(t *testing.T) { 1227 var receivedPID uint32 1228 var receivedPtyData []byte 1229 handler := &testProcessHandler{ 1230 sendInputFn: func(_ context.Context, req *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { 1231 if sel, ok := req.Msg.Process.Selector.(*process.ProcessSelector_Pid); ok { 1232 receivedPID = sel.Pid 1233 } 1234 if ptyData := req.Msg.Input.GetPty(); len(ptyData) > 0 { 1235 receivedPtyData = ptyData 1236 } 1237 return connect.NewResponse(&process.SendInputResponse{}), nil 1238 }, 1239 } 1240 pty, ts := newTestPty(handler) 1241 defer ts.Close() 1242 1243 err := pty.SendInput(context.Background(), 200, []byte("ls\n")) 1244 if err != nil { 1245 t.Fatalf("SendInput error: %v", err) 1246 } 1247 if receivedPID != 200 { 1248 t.Errorf("PID = %d, want 200", receivedPID) 1249 } 1250 if string(receivedPtyData) != "ls\n" { 1251 t.Errorf("pty data = %q, want %q", string(receivedPtyData), "ls\n") 1252 } 1253 } 1254 1255 func TestPtyResize(t *testing.T) { 1256 var receivedCols, receivedRows uint32 1257 handler := &testProcessHandler{ 1258 updateFn: func(_ context.Context, req *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) { 1259 if req.Msg.Pty != nil && req.Msg.Pty.Size != nil { 1260 receivedCols = req.Msg.Pty.Size.Cols 1261 receivedRows = req.Msg.Pty.Size.Rows 1262 } 1263 return connect.NewResponse(&process.UpdateResponse{}), nil 1264 }, 1265 } 1266 pty, ts := newTestPty(handler) 1267 defer ts.Close() 1268 1269 err := pty.Resize(context.Background(), 200, PtySize{Cols: 200, Rows: 50}) 1270 if err != nil { 1271 t.Fatalf("Resize error: %v", err) 1272 } 1273 if receivedCols != 200 || receivedRows != 50 { 1274 t.Errorf("resize = %dx%d, want 200x50", receivedCols, receivedRows) 1275 } 1276 } 1277 1278 func TestPtyKill(t *testing.T) { 1279 var receivedPID uint32 1280 var receivedSignal process.Signal 1281 handler := &testProcessHandler{ 1282 sendSignalFn: func(_ context.Context, req *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { 1283 if sel, ok := req.Msg.Process.Selector.(*process.ProcessSelector_Pid); ok { 1284 receivedPID = sel.Pid 1285 } 1286 receivedSignal = req.Msg.Signal 1287 return connect.NewResponse(&process.SendSignalResponse{}), nil 1288 }, 1289 } 1290 pty, ts := newTestPty(handler) 1291 defer ts.Close() 1292 1293 err := pty.Kill(context.Background(), 300) 1294 if err != nil { 1295 t.Fatalf("Kill error: %v", err) 1296 } 1297 if receivedPID != 300 { 1298 t.Errorf("PID = %d, want 300", receivedPID) 1299 } 1300 if receivedSignal != process.Signal_SIGNAL_SIGKILL { 1301 t.Errorf("Signal = %v, want SIGKILL", receivedSignal) 1302 } 1303 } 1304 1305 // ========================================================================= 1306 // 6. 纯函数 / Option 补充测试 1307 // ========================================================================= 1308 1309 func TestEntryInfoFromProtoFull(t *testing.T) { 1310 ts := timestamppb.New(time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)) 1311 symlink := "/link/target" 1312 proto := &filesystem.EntryInfo{ 1313 Name: "link.txt", 1314 Type: filesystem.FileType_FILE_TYPE_FILE, 1315 Path: "/home/user/link.txt", 1316 Size: 512, 1317 Mode: 0o777, 1318 Permissions: "rwxrwxrwx", 1319 Owner: "root", 1320 Group: "root", 1321 ModifiedTime: ts, 1322 SymlinkTarget: &symlink, 1323 } 1324 1325 info := entryInfoFromProto(proto) 1326 if info == nil { 1327 t.Fatal("entryInfoFromProto returned nil") 1328 } 1329 if info.Name != "link.txt" { 1330 t.Errorf("Name = %q", info.Name) 1331 } 1332 if info.Type != FileTypeFile { 1333 t.Errorf("Type = %q", info.Type) 1334 } 1335 if info.Path != "/home/user/link.txt" { 1336 t.Errorf("Path = %q", info.Path) 1337 } 1338 if info.Size != 512 { 1339 t.Errorf("Size = %d", info.Size) 1340 } 1341 if info.Mode != 0o777 { 1342 t.Errorf("Mode = %o", info.Mode) 1343 } 1344 if info.Permissions != "rwxrwxrwx" { 1345 t.Errorf("Permissions = %q", info.Permissions) 1346 } 1347 if info.Owner != "root" { 1348 t.Errorf("Owner = %q", info.Owner) 1349 } 1350 if info.Group != "root" { 1351 t.Errorf("Group = %q", info.Group) 1352 } 1353 wantTime := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) 1354 if !info.ModifiedTime.Equal(wantTime) { 1355 t.Errorf("ModifiedTime = %v, want %v", info.ModifiedTime, wantTime) 1356 } 1357 if info.SymlinkTarget == nil || *info.SymlinkTarget != "/link/target" { 1358 t.Errorf("SymlinkTarget = %v", info.SymlinkTarget) 1359 } 1360 } 1361 1362 func TestEntryInfoFromProtoDirectory(t *testing.T) { 1363 proto := &filesystem.EntryInfo{ 1364 Name: "docs", 1365 Type: filesystem.FileType_FILE_TYPE_DIRECTORY, 1366 Path: "/docs", 1367 } 1368 info := entryInfoFromProto(proto) 1369 if info.Type != FileTypeDirectory { 1370 t.Errorf("Type = %q, want %q", info.Type, FileTypeDirectory) 1371 } 1372 } 1373 1374 func TestFilesystemEventFromProto(t *testing.T) { 1375 tests := []struct { 1376 protoType filesystem.EventType 1377 wantType EventType 1378 }{ 1379 {filesystem.EventType_EVENT_TYPE_CREATE, EventCreate}, 1380 {filesystem.EventType_EVENT_TYPE_WRITE, EventWrite}, 1381 {filesystem.EventType_EVENT_TYPE_REMOVE, EventRemove}, 1382 {filesystem.EventType_EVENT_TYPE_RENAME, EventRename}, 1383 {filesystem.EventType_EVENT_TYPE_CHMOD, EventChmod}, 1384 } 1385 1386 for _, tt := range tests { 1387 ev := filesystemEventFromProto(&filesystem.FilesystemEvent{ 1388 Name: "test.txt", 1389 Type: tt.protoType, 1390 }) 1391 if ev.Name != "test.txt" { 1392 t.Errorf("Name = %q, want test.txt", ev.Name) 1393 } 1394 if ev.Type != tt.wantType { 1395 t.Errorf("EventType for %v = %q, want %q", tt.protoType, ev.Type, tt.wantType) 1396 } 1397 } 1398 } 1399 1400 func TestCommandOptions(t *testing.T) { 1401 called := false 1402 opts := applyCommandOpts([]CommandOption{ 1403 WithEnvs(map[string]string{"FOO": "BAR"}), 1404 WithCwd("/tmp"), 1405 WithCommandUser("admin"), 1406 WithTag("test-tag"), 1407 WithOnStdout(func([]byte) { called = true }), 1408 WithOnStderr(func([]byte) {}), 1409 WithTimeout(5 * time.Second), 1410 }) 1411 1412 if opts.user != "admin" { 1413 t.Errorf("user = %q, want admin", opts.user) 1414 } 1415 if opts.cwd != "/tmp" { 1416 t.Errorf("cwd = %q, want /tmp", opts.cwd) 1417 } 1418 if opts.tag != "test-tag" { 1419 t.Errorf("tag = %q, want test-tag", opts.tag) 1420 } 1421 if opts.envs["FOO"] != "BAR" { 1422 t.Errorf("envs = %v", opts.envs) 1423 } 1424 if opts.timeout != 5*time.Second { 1425 t.Errorf("timeout = %v, want 5s", opts.timeout) 1426 } 1427 if opts.onStdout == nil { 1428 t.Error("onStdout is nil") 1429 } else { 1430 opts.onStdout(nil) 1431 if !called { 1432 t.Error("onStdout callback not invoked") 1433 } 1434 } 1435 if opts.onStderr == nil { 1436 t.Error("onStderr is nil") 1437 } 1438 } 1439 1440 func TestCommandOptionsDefaults(t *testing.T) { 1441 opts := applyCommandOpts(nil) 1442 if opts.user != "user" { 1443 t.Errorf("default user = %q, want user", opts.user) 1444 } 1445 if opts.cwd != "" { 1446 t.Errorf("default cwd = %q, want empty", opts.cwd) 1447 } 1448 if opts.timeout != 0 { 1449 t.Errorf("default timeout = %v, want 0", opts.timeout) 1450 } 1451 } 1452 1453 func TestFilesystemListOptions(t *testing.T) { 1454 opts := applyListOpts([]ListOption{ 1455 WithDepth(5), 1456 WithListUser("admin"), 1457 }) 1458 if opts.depth != 5 { 1459 t.Errorf("depth = %d, want 5", opts.depth) 1460 } 1461 if opts.user != "admin" { 1462 t.Errorf("user = %q, want admin", opts.user) 1463 } 1464 } 1465 1466 func TestFilesystemListOptionsDefaults(t *testing.T) { 1467 opts := applyListOpts(nil) 1468 if opts.depth != 1 { 1469 t.Errorf("default depth = %d, want 1", opts.depth) 1470 } 1471 if opts.user != "user" { 1472 t.Errorf("default user = %q, want user", opts.user) 1473 } 1474 } 1475 1476 func TestWatchOptions(t *testing.T) { 1477 opts := applyWatchOpts([]WatchOption{ 1478 WithRecursive(true), 1479 WithWatchUser("admin"), 1480 }) 1481 if !opts.recursive { 1482 t.Error("recursive = false, want true") 1483 } 1484 if opts.user != "admin" { 1485 t.Errorf("user = %q, want admin", opts.user) 1486 } 1487 } 1488 1489 func TestWatchOptionsDefaults(t *testing.T) { 1490 opts := applyWatchOpts(nil) 1491 if opts.recursive { 1492 t.Error("default recursive = true, want false") 1493 } 1494 if opts.user != "user" { 1495 t.Errorf("default user = %q, want user", opts.user) 1496 } 1497 } 1498 1499 func TestFilesystemOptions(t *testing.T) { 1500 opts := applyFilesystemOpts([]FilesystemOption{ 1501 WithUser("root"), 1502 }) 1503 if opts.user != "root" { 1504 t.Errorf("user = %q, want root", opts.user) 1505 } 1506 } 1507 1508 func TestFilesystemOptionsDefaults(t *testing.T) { 1509 opts := applyFilesystemOpts(nil) 1510 if opts.user != "user" { 1511 t.Errorf("default user = %q, want user", opts.user) 1512 } 1513 } 1514 1515 // ========================================================================= 1516 // helpers 1517 // ========================================================================= 1518 1519 // rewriteTransport 将所有 HTTP 请求重定向到指定的 baseURL(test server)。 1520 type rewriteTransport struct { 1521 base http.RoundTripper 1522 baseURL string 1523 } 1524 1525 func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { 1526 // 保留原始 path 和 query,替换 scheme + host 1527 orig := req.URL.String() 1528 _ = orig 1529 req.URL.Scheme = "http" 1530 req.URL.Host = t.baseURL[len("http://"):] 1531 return t.base.RoundTrip(req) 1532 } 1533 1534 // TestKeepaliveHeaderInjected 验证通过 Sandbox.Pty() 创建的 PTY 会话包含 Keepalive-Ping-Interval header。 1535 func TestKeepaliveHeaderInjected(t *testing.T) { 1536 var receivedHeader string 1537 handler := &testProcessHandler{ 1538 startFn: func(_ context.Context, req *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 1539 receivedHeader = req.Header().Get(keepalivePingHeader) 1540 stream.Send(&process.StartResponse{ 1541 Event: &process.ProcessEvent{ 1542 Event: &process.ProcessEvent_Start{ 1543 Start: &process.ProcessEvent_StartEvent{Pid: 300}, 1544 }, 1545 }, 1546 }) 1547 stream.Send(&process.StartResponse{ 1548 Event: &process.ProcessEvent{ 1549 Event: &process.ProcessEvent_End{ 1550 End: &process.ProcessEvent_EndEvent{ExitCode: 0}, 1551 }, 1552 }, 1553 }) 1554 return nil 1555 }, 1556 } 1557 1558 mux := http.NewServeMux() 1559 path, h := processconnect.NewProcessHandler(handler) 1560 mux.Handle(path, h) 1561 ts := httptest.NewServer(mux) 1562 defer ts.Close() 1563 1564 // 通过 Sandbox.Pty() 走完整路径,触发 processClient() 中的 keepaliveInterceptor 1565 domain := "test.dev" 1566 c := &Client{config: &Config{ 1567 HTTPClient: &http.Client{ 1568 Transport: &rewriteTransport{base: http.DefaultTransport, baseURL: ts.URL}, 1569 }, 1570 }} 1571 sb := &Sandbox{sandboxID: "sb-test", domain: &domain, client: c} 1572 1573 handle, err := sb.Pty().Create(context.Background(), PtySize{Cols: 80, Rows: 24}) 1574 if err != nil { 1575 t.Fatalf("Create error: %v", err) 1576 } 1577 if _, err := handle.Wait(); err != nil { 1578 t.Fatalf("Wait error: %v", err) 1579 } 1580 1581 if receivedHeader != keepalivePingIntervalSec { 1582 t.Errorf("Keepalive-Ping-Interval header = %q, want %q", receivedHeader, keepalivePingIntervalSec) 1583 } 1584 } 1585 1586 // TestKeepaliveHeaderInjectedCommands 验证通过 Sandbox.Commands().Start() 创建的命令会话包含 Keepalive-Ping-Interval header。 1587 func TestKeepaliveHeaderInjectedCommands(t *testing.T) { 1588 var receivedHeader string 1589 handler := &testProcessHandler{ 1590 startFn: func(_ context.Context, req *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error { 1591 receivedHeader = req.Header().Get(keepalivePingHeader) 1592 stream.Send(&process.StartResponse{ 1593 Event: &process.ProcessEvent{ 1594 Event: &process.ProcessEvent_Start{ 1595 Start: &process.ProcessEvent_StartEvent{Pid: 400}, 1596 }, 1597 }, 1598 }) 1599 stream.Send(&process.StartResponse{ 1600 Event: &process.ProcessEvent{ 1601 Event: &process.ProcessEvent_End{ 1602 End: &process.ProcessEvent_EndEvent{ExitCode: 0}, 1603 }, 1604 }, 1605 }) 1606 return nil 1607 }, 1608 } 1609 1610 mux := http.NewServeMux() 1611 path, h := processconnect.NewProcessHandler(handler) 1612 mux.Handle(path, h) 1613 ts := httptest.NewServer(mux) 1614 defer ts.Close() 1615 1616 // 通过 Sandbox.Commands() 走完整路径,触发 processClient() 中的 keepaliveInterceptor 1617 domain := "test.dev" 1618 c := &Client{config: &Config{ 1619 HTTPClient: &http.Client{ 1620 Transport: &rewriteTransport{base: http.DefaultTransport, baseURL: ts.URL}, 1621 }, 1622 }} 1623 sb := &Sandbox{sandboxID: "sb-test", domain: &domain, client: c} 1624 1625 handle, err := sb.Commands().Start(context.Background(), "echo hello") 1626 if err != nil { 1627 t.Fatalf("Start error: %v", err) 1628 } 1629 if _, err := handle.Wait(); err != nil { 1630 t.Fatalf("Wait error: %v", err) 1631 } 1632 1633 if receivedHeader != keepalivePingIntervalSec { 1634 t.Errorf("Keepalive-Ping-Interval header = %q, want %q", receivedHeader, keepalivePingIntervalSec) 1635 } 1636 }