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  }