From b28db454afa92fecba8f247f6a1d9fc4c8691a85 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 17 May 2026 11:36:35 +0000 Subject: [PATCH] test(cli,web): raise coverage for workspace switch paths Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/cli/web_command_test.go | 359 +++++++++++++++++++++ web/src/components/layout/Sidebar.test.tsx | 23 ++ web/src/stores/useWorkspaceStore.test.ts | 41 +++ 3 files changed, 423 insertions(+) diff --git a/internal/cli/web_command_test.go b/internal/cli/web_command_test.go index 0c0d23351..dd48daab4 100644 --- a/internal/cli/web_command_test.go +++ b/internal/cli/web_command_test.go @@ -1,15 +1,21 @@ package cli import ( + "bytes" "context" + "encoding/json" "errors" "io/fs" "log" + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" + "time" ) // writeWebCommandTestFile 写入 web 命令测试所需的最小文件内容,避免各测试重复拼装目录。 @@ -286,3 +292,356 @@ func TestRunWebCommandSkipBuildStillUsesEmbeddedAssets(t *testing.T) { t.Fatal("startGatewayServer staticFS = nil, want embedded assets FS") } } + +func TestValidateStaticDirAndResolveOverride(t *testing.T) { + tempDir := t.TempDir() + staticDir := filepath.Join(tempDir, "dist") + if _, err := validateStaticDir(staticDir); err == nil { + t.Fatal("validateStaticDir() error = nil, want missing index.html error") + } + + writeWebCommandTestFile(t, filepath.Join(staticDir, "index.html"), "") + got, err := resolveWebStaticDir(staticDir) + if err != nil { + t.Fatalf("resolveWebStaticDir() error = %v", err) + } + if got != staticDir { + t.Fatalf("resolveWebStaticDir() = %q, want %q", got, staticDir) + } + + dirIndex := filepath.Join(tempDir, "bad-dist", "index.html") + if err := os.MkdirAll(dirIndex, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dirIndex, err) + } + if _, err := validateStaticDir(filepath.Dir(dirIndex)); err == nil || !strings.Contains(err.Error(), "is a directory") { + t.Fatalf("validateStaticDir() error = %v, want directory error", err) + } +} + +func TestIsStaleFrontendBuildBranches(t *testing.T) { + tempDir := t.TempDir() + webDir := filepath.Join(tempDir, "web") + srcDir := filepath.Join(webDir, "src") + distIndex := filepath.Join(webDir, "dist", "index.html") + + if !isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = false, want true when dist is missing") + } + + writeWebCommandTestFile(t, distIndex, "") + writeWebCommandTestFile(t, filepath.Join(webDir, "package.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(webDir, "vite.config.ts"), "export default {}") + writeWebCommandTestFile(t, filepath.Join(webDir, "tsconfig.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(srcDir, "main.ts"), "console.log('ok')") + + distTime := time.Now() + if err := os.Chtimes(distIndex, distTime, distTime); err != nil { + t.Fatalf("chtimes dist: %v", err) + } + olderTime := distTime.Add(-time.Minute) + for _, path := range []string{ + filepath.Join(webDir, "package.json"), + filepath.Join(webDir, "vite.config.ts"), + filepath.Join(webDir, "tsconfig.json"), + filepath.Join(srcDir, "main.ts"), + } { + if err := os.Chtimes(path, olderTime, olderTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } + + if isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = true, want false when dist is newest") + } + + newerTime := distTime.Add(time.Minute) + packageJSON := filepath.Join(webDir, "package.json") + if err := os.Chtimes(packageJSON, newerTime, newerTime); err != nil { + t.Fatalf("chtimes package.json: %v", err) + } + if !isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = false, want true when package.json is newer") + } + + if err := os.Chtimes(packageJSON, olderTime, olderTime); err != nil { + t.Fatalf("restore package.json time: %v", err) + } + srcFile := filepath.Join(srcDir, "main.ts") + if err := os.Chtimes(srcFile, newerTime, newerTime); err != nil { + t.Fatalf("chtimes src: %v", err) + } + if !isStaleFrontendBuild(webDir) { + t.Fatal("isStaleFrontendBuild() = false, want true when src file is newer") + } +} + +func TestBuildFrontendAndReadGatewayToken(t *testing.T) { + tempDir := t.TempDir() + webDir := filepath.Join(tempDir, "web") + if err := os.MkdirAll(webDir, 0o755); err != nil { + t.Fatalf("mkdir webdir: %v", err) + } + + npmPath := filepath.Join(tempDir, "npm") + script := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "if [ \"$1\" = \"install\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then", + " mkdir -p \"$PWD/dist\"", + " printf '' > \"$PWD/dist/index.html\"", + " exit 0", + "fi", + "exit 1", + }, "\n") + if err := os.WriteFile(npmPath, []byte(script), 0o755); err != nil { + t.Fatalf("write npm stub: %v", err) + } + stubWebCommandHooks(t, nil, nil, func(string) (string, error) { + return npmPath, nil + }) + + logger := log.New(&bytes.Buffer{}, "", 0) + if err := buildFrontend(webDir, logger); err != nil { + t.Fatalf("buildFrontend() error = %v", err) + } + if _, err := os.Stat(filepath.Join(webDir, "dist", "index.html")); err != nil { + t.Fatalf("built dist/index.html missing: %v", err) + } + + homeDir := filepath.Join(tempDir, "home") + authDir := filepath.Join(homeDir, ".neocode") + if err := os.MkdirAll(authDir, 0o755); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + authData, err := json.Marshal(map[string]string{"token": " secret-token "}) + if err != nil { + t.Fatalf("marshal auth data: %v", err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), authData, 0o644); err != nil { + t.Fatalf("write auth.json: %v", err) + } + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", homeDir); err != nil { + t.Fatalf("set HOME: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + if got := readGatewayToken(); got != "secret-token" { + t.Fatalf("readGatewayToken() = %q, want %q", got, "secret-token") + } +} + +func TestWaitForGatewayAndOpenBrowserAndResolveListenAddress(t *testing.T) { + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + authDir := filepath.Join(homeDir, ".neocode") + if err := os.MkdirAll(authDir, 0o755); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + authData, err := json.Marshal(map[string]string{"token": "token-123"}) + if err != nil { + t.Fatalf("marshal auth data: %v", err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), authData, 0o644); err != nil { + t.Fatalf("write auth.json: %v", err) + } + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", homeDir); err != nil { + t.Fatalf("set HOME: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + openLog := filepath.Join(tempDir, "opened-url.txt") + scriptPath := filepath.Join(binDir, "xdg-open") + script := "#!/bin/sh\nprintf '%s' \"$1\" > \"" + openLog + "\"\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write xdg-open stub: %v", err) + } + originalPath := os.Getenv("PATH") + if err := os.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath); err != nil { + t.Fatalf("set PATH: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("PATH", originalPath) + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + logger := log.New(&bytes.Buffer{}, "", 0) + waitForGatewayAndOpenBrowser(context.Background(), strings.TrimPrefix(server.URL, "http://"), logger) + + var data []byte + var readErr error + for i := 0; i < 20; i++ { + data, readErr = os.ReadFile(openLog) + if readErr == nil { + break + } + time.Sleep(25 * time.Millisecond) + } + if readErr != nil { + t.Fatalf("read open log: %v", readErr) + } + if got := string(data); got != server.URL+"/?token=token-123" { + t.Fatalf("opened url = %q, want %q", got, server.URL+"/?token=token-123") + } + + if got := resolveWebListenAddress("bad-address"); got != "bad-address" { + t.Fatalf("resolveWebListenAddress() = %q, want original invalid address", got) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + occupied := listener.Addr().String() + resolved := resolveWebListenAddress(occupied) + if resolved == occupied { + t.Fatalf("resolveWebListenAddress() = %q, want fallback port", resolved) + } +} + +func TestResolveWebStaticDirCurrentWorkdirAndReadGatewayTokenInvalid(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "dist", "index.html"), "") + stubResolveExecutablePath(t, func() (string, error) { + return "", errors.New("skip executable lookup") + }) + + got, err := resolveWebStaticDir("") + if err != nil { + t.Fatalf("resolveWebStaticDir() error = %v", err) + } + if got != filepath.Join(tempDir, "web", "dist") { + t.Fatalf("resolveWebStaticDir() = %q, want cwd web/dist", got) + } + + homeDir := filepath.Join(tempDir, "home") + authDir := filepath.Join(homeDir, ".neocode") + if err := os.MkdirAll(authDir, 0o755); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte("{invalid"), 0o644); err != nil { + t.Fatalf("write invalid auth.json: %v", err) + } + originalHome := os.Getenv("HOME") + if err := os.Setenv("HOME", homeDir); err != nil { + t.Fatalf("set HOME: %v", err) + } + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + if got := readGatewayToken(); got != "" { + t.Fatalf("readGatewayToken() = %q, want empty on invalid json", got) + } +} + +func TestRunWebCommandFallbackAndSkipBuildErrors(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + stubResolveExecutablePath(t, func() (string, error) { + return "", errors.New("skip executable lookup") + }) + stubWebCommandEmbeddedAssets(t, nil, false) + + err := runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + SkipBuild: true, + OpenBrowser: false, + Workdir: tempDir, + }) + if err == nil || !strings.Contains(err.Error(), "--skip-build is set") { + t.Fatalf("runWebCommand() error = %v, want skip-build missing assets error", err) + } + + writeWebCommandTestFile(t, filepath.Join(tempDir, "web", "package.json"), "{}") + err = runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + OpenBrowser: false, + Workdir: tempDir, + }) + if err == nil || !strings.Contains(err.Error(), "frontend build failed on this machine") { + t.Fatalf("runWebCommand() error = %v, want build failure error", err) + } +} + +func TestRunWebCommandRebuildsStaleDistAndDefaultsWorkdir(t *testing.T) { + tempDir := t.TempDir() + chdirForWebCommandTest(t, tempDir) + webDir := filepath.Join(tempDir, "web") + writeWebCommandTestFile(t, filepath.Join(webDir, "package.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(webDir, "vite.config.ts"), "export default {}") + writeWebCommandTestFile(t, filepath.Join(webDir, "tsconfig.json"), "{}") + writeWebCommandTestFile(t, filepath.Join(webDir, "src", "main.ts"), "console.log('stale')") + writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "") + + distIndex := filepath.Join(webDir, "dist", "index.html") + oldTime := time.Now().Add(-time.Hour) + newTime := time.Now() + if err := os.Chtimes(distIndex, oldTime, oldTime); err != nil { + t.Fatalf("chtimes dist: %v", err) + } + for _, path := range []string{ + filepath.Join(webDir, "package.json"), + filepath.Join(webDir, "vite.config.ts"), + filepath.Join(webDir, "tsconfig.json"), + filepath.Join(webDir, "src", "main.ts"), + } { + if err := os.Chtimes(path, newTime, newTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } + + buildCalled := false + var captured gatewayCommandOptions + sentinelErr := errors.New("stop after start") + stubWebCommandHooks( + t, + func(_ context.Context, options gatewayCommandOptions, _ string, _ fs.FS, _ func(string)) error { + captured = options + return sentinelErr + }, + func(webDir string, _ *log.Logger) error { + buildCalled = true + writeWebCommandTestFile(t, filepath.Join(webDir, "dist", "index.html"), "") + return nil + }, + nil, + ) + + err := runWebCommand(context.Background(), webCommandOptions{ + HTTPAddress: "127.0.0.1:8080", + LogLevel: "info", + OpenBrowser: false, + }) + if !errors.Is(err, sentinelErr) { + t.Fatalf("runWebCommand() error = %v, want sentinel error", err) + } + if !buildCalled { + t.Fatal("runWebCommand() did not rebuild stale frontend dist") + } + if captured.Workdir != tempDir { + t.Fatalf("gateway workdir = %q, want cwd %q", captured.Workdir, tempDir) + } +} diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index eddee55e0..1524b17b1 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -351,6 +351,29 @@ describe('Sidebar ProviderModal', () => { expect(addWorkspaceButton as HTMLButtonElement).toBeDisabled() }) + it('switches another workspace but does not re-switch the current workspace', async () => { + const switchWorkspace = vi.fn().mockResolvedValue(undefined) + useWorkspaceStore.setState({ + workspaces: [ + { hash: 'w1', path: '/workspace-one', name: 'Workspace One', createdAt: '1', updatedAt: '1' }, + { hash: 'w2', path: '/workspace-two', name: 'Workspace Two', createdAt: '1', updatedAt: '1' }, + ], + currentWorkspaceHash: 'w1', + changing: false, + switchWorkspace, + } as any) + + render() + + fireEvent.click(screen.getByRole('button', { name: /Workspace Two/i })) + await waitFor(() => { + expect(switchWorkspace).toHaveBeenCalledWith('w2', mockGatewayAPI) + }) + + fireEvent.click(screen.getByRole('button', { name: /Workspace One/i })) + expect(switchWorkspace).toHaveBeenCalledTimes(1) + }) + it('immediately dispatches collapsed-rail actions', async () => { const toggleSidebar = vi.fn() const prepareNewChat = vi.fn() diff --git a/web/src/stores/useWorkspaceStore.test.ts b/web/src/stores/useWorkspaceStore.test.ts index 67b63eb1f..13815143f 100644 --- a/web/src/stores/useWorkspaceStore.test.ts +++ b/web/src/stores/useWorkspaceStore.test.ts @@ -222,6 +222,47 @@ describe('useWorkspaceStore', () => { expect(showToast).toHaveBeenCalledWith('Failed to create workspace', 'error') }) + it('renameWorkspace updates the matching workspace name', async () => { + const gatewayAPI = { + renameWorkspace: vi.fn().mockResolvedValue(undefined), + } as any + useWorkspaceStore.setState({ + workspaces: [ + { hash: 'w1', path: '/1', name: 'Old', createdAt: '1', updatedAt: '1' }, + { hash: 'w2', path: '/2', name: 'Keep', createdAt: '1', updatedAt: '1' }, + ], + } as any) + + await useWorkspaceStore.getState().renameWorkspace('w1', 'New', gatewayAPI) + + expect(gatewayAPI.renameWorkspace).toHaveBeenCalledWith('w1', 'New') + expect(useWorkspaceStore.getState().workspaces.map((w) => w.name)).toEqual(['New', 'Keep']) + }) + + it('renameWorkspace failure reports toast', async () => { + const showToast = vi.fn() + useUIStore.setState({ showToast } as any) + const gatewayAPI = { + renameWorkspace: vi.fn().mockRejectedValue(new Error('boom')), + } as any + + await useWorkspaceStore.getState().renameWorkspace('w1', 'New', gatewayAPI) + + expect(showToast).toHaveBeenCalledWith('Failed to rename workspace', 'error') + }) + + it('deleteWorkspace failure reports toast', async () => { + const showToast = vi.fn() + useUIStore.setState({ showToast } as any) + const gatewayAPI = { + deleteWorkspace: vi.fn().mockRejectedValue(new Error('boom')), + } as any + + await useWorkspaceStore.getState().deleteWorkspace('w1', gatewayAPI) + + expect(showToast).toHaveBeenCalledWith('Failed to delete workspace', 'error') + }) + it('deleteWorkspace switches to remaining first workspace when current is removed', async () => { const switchWorkspace = vi.spyOn(useWorkspaceStore.getState(), 'switchWorkspace') useWorkspaceStore.setState({