test: reach complete unit coverage
This commit is contained in:
1 parent
8633246eff
commit
fafb6f47b9
123 files changed
+11689
-822
No files matched your search
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#dd4343"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 100.0%"><title>coverage: 100.0%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4b0"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">100.0%</text><text x="865" y="150" fill-opacity=".3" textLength="430">100.0%</text></g><text x="865" y="140" textLength="430">100.0%</text></g></g></svg>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -26,13 +26,29 @@ import (
|
|||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type legacyTokenGetter interface {
|
||||||
|
GetToken() (string, string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
newAccessTokenProvider = func(configDir string) accessTokenGetter {
|
||||||
|
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||||
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
newLegacyTokenManager = func(configDir string) legacyTokenGetter {
|
||||||
|
manager := authpkg.NewManager(configDir, nil)
|
||||||
|
configureLegacyAuthManagerCompatibility(manager)
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
||||||
// the same host compatibility hooks as MCP. It mirrors the former body of
|
// the same host compatibility hooks as MCP. It mirrors the former body of
|
||||||
// getCachedRuntimeToken (excluding process-level cache and timing).
|
// getCachedRuntimeToken (excluding process-level cache and timing).
|
||||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
provider := newAccessTokenProvider(configDir)
|
||||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
|
||||||
token, tokenErr := provider.GetAccessToken(ctx)
|
token, tokenErr := provider.GetAccessToken(ctx)
|
||||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
||||||
return strings.TrimSpace(token), nil
|
return strings.TrimSpace(token), nil
|
||||||
@@ -40,8 +56,7 @@ func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, e
|
|||||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
||||||
return "", tokenErr
|
return "", tokenErr
|
||||||
}
|
}
|
||||||
manager := authpkg.NewManager(configDir, nil)
|
manager := newLegacyTokenManager(configDir)
|
||||||
configureLegacyAuthManagerCompatibility(manager)
|
|
||||||
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
||||||
return strings.TrimSpace(leg), nil
|
return strings.TrimSpace(leg), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -38,6 +38,19 @@ type apiFlags struct {
|
|||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type appTokenGetter interface {
|
||||||
|
GetToken(context.Context) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAppTokenProvider = func(configDir, appKey, appSecret string) appTokenGetter {
|
||||||
|
return &authpkg.AppTokenProvider{ConfigDir: configDir, AppKey: appKey, AppSecret: appSecret}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
apiClientID = authpkg.ClientID
|
||||||
|
apiClientSecret = authpkg.ClientSecret
|
||||||
|
)
|
||||||
|
|
||||||
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
|
// newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls.
|
||||||
func newAPICommand(flags *GlobalFlags) *cobra.Command {
|
func newAPICommand(flags *GlobalFlags) *cobra.Command {
|
||||||
af := &apiFlags{}
|
af := &apiFlags{}
|
||||||
@@ -271,10 +284,7 @@ func parseQueryStringToJSON(rawQuery string) string {
|
|||||||
return "{}"
|
return "{}"
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(paramsMap)
|
data, _ := json.Marshal(paramsMap)
|
||||||
if err != nil {
|
|
||||||
return "{}"
|
|
||||||
}
|
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,8 +299,8 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve app credentials (clientID/clientSecret).
|
// Resolve app credentials (clientID/clientSecret).
|
||||||
appKey := authpkg.ClientID()
|
appKey := apiClientID()
|
||||||
appSecret := authpkg.ClientSecret()
|
appSecret := apiClientSecret()
|
||||||
|
|
||||||
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
|
if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") {
|
||||||
return "", apperrors.NewAuth(
|
return "", apperrors.NewAuth(
|
||||||
@@ -308,11 +318,7 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro
|
|||||||
|
|
||||||
// Use AppTokenProvider for automatic caching and refresh.
|
// Use AppTokenProvider for automatic caching and refresh.
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
provider := &authpkg.AppTokenProvider{
|
provider := newAppTokenProvider(configDir, appKey, appSecret)
|
||||||
ConfigDir: configDir,
|
|
||||||
AppKey: appKey,
|
|
||||||
AppSecret: appSecret,
|
|
||||||
}
|
|
||||||
token, err := provider.GetToken(ctx)
|
token, err := provider.GetToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
|
return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err))
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type appFailWriter struct{ err error }
|
||||||
|
|
||||||
|
func (w appFailWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
|
||||||
|
type fakeAccessTokenGetter struct {
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
|
||||||
|
return g.token, g.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeLegacyTokenGetter struct {
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g fakeLegacyTokenGetter) GetToken() (string, string, error) {
|
||||||
|
return g.token, "test", g.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeAppTokenGetter struct {
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g fakeAppTokenGetter) GetToken(context.Context) (string, error) { return g.token, g.err }
|
||||||
|
|
||||||
|
type fakeSkillDirEntry struct{ dir bool }
|
||||||
|
|
||||||
|
func (e fakeSkillDirEntry) Name() string { return "entry" }
|
||||||
|
func (e fakeSkillDirEntry) IsDir() bool { return e.dir }
|
||||||
|
func (e fakeSkillDirEntry) Type() os.FileMode { return 0 }
|
||||||
|
func (e fakeSkillDirEntry) Info() (os.FileInfo, error) { return nil, nil }
|
||||||
|
|
||||||
|
func docPreflightServer(t *testing.T, result map[string]any) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request.ID,
|
||||||
|
"result": result,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocDownloadPreflightCoverage(t *testing.T) {
|
||||||
|
runner := &runtimeRunner{}
|
||||||
|
base := executor.Invocation{CanonicalProduct: "doc", Tool: "download_file", Params: map[string]any{"nodeId": " node "}}
|
||||||
|
if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{CanonicalProduct: "DOC", Tool: "download_file"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !isDocDownloadInvocation(base) || docDownloadNodeID(map[string]any{"node": " n "}) != "n" || docDownloadNodeID(map[string]any{"dentryUuid": " d "}) != "d" || docDownloadNodeID(map[string]any{"nodeId": 1}) != "" {
|
||||||
|
t.Fatal("doc download invocation helpers returned unexpected values")
|
||||||
|
}
|
||||||
|
if documentInfoExtension(map[string]any{"data": map[string]any{"extension": " doc "}}) != "doc" ||
|
||||||
|
documentInfoExtension(map[string]any{"extension": " pdf "}) != "pdf" ||
|
||||||
|
stringAtPath(map[string]any{"x": "value"}, "x", "nested") != "" ||
|
||||||
|
stringAtPath(map[string]any{"x": 1}, "x") != "" {
|
||||||
|
t.Fatal("document extension helpers returned unexpected values")
|
||||||
|
}
|
||||||
|
if unsupportedAXLSDownloadError() == nil {
|
||||||
|
t.Fatal("missing AXLS validation error")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
|
hookErr := errors.New("classified")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
result map[string]any
|
||||||
|
hooks *edition.Hooks
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "ok", result: map[string]any{"content": map[string]any{"result": map[string]any{"extension": "docx"}}}, hooks: &edition.Hooks{}},
|
||||||
|
{name: "edition classifier", result: map[string]any{"content": map[string]any{}}, hooks: &edition.Hooks{ClassifyToolResult: func(map[string]any) error { return hookErr }}, want: "classified"},
|
||||||
|
{name: "pat", result: map[string]any{"content": map[string]any{"errorCode": "PAT_NO_PERMISSION"}}, hooks: &edition.Hooks{}, want: "PAT_NO_PERMISSION"},
|
||||||
|
{name: "mcp error", result: map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "mcp failed"}}}, hooks: &edition.Hooks{}, want: "mcp failed"},
|
||||||
|
{name: "business error", result: map[string]any{"content": map[string]any{"success": false, "errorMsg": "business failed"}}, hooks: &edition.Hooks{}, want: "business failed"},
|
||||||
|
{name: "axls", result: map[string]any{"content": map[string]any{"data": map[string]any{"extension": "AXLS"}}}, hooks: &edition.Hooks{}, want: "extension=axls"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
edition.Override(tc.hooks)
|
||||||
|
server := docPreflightServer(t, tc.result)
|
||||||
|
defer server.Close()
|
||||||
|
client := transport.NewClient(nil)
|
||||||
|
client.TrustedDomains = []string{"127.0.0.1"}
|
||||||
|
err := runner.preflightDocDownload(context.Background(), client, server.URL, base)
|
||||||
|
if tc.want == "" && err != nil {
|
||||||
|
t.Fatalf("preflight error = %v", err)
|
||||||
|
}
|
||||||
|
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
||||||
|
t.Fatalf("preflight error = %v, want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
server := docPreflightServer(t, map[string]any{})
|
||||||
|
endpoint := server.URL
|
||||||
|
server.Close()
|
||||||
|
client := transport.NewClient(nil)
|
||||||
|
client.TrustedDomains = []string{"127.0.0.1"}
|
||||||
|
client.MaxRetries = 0
|
||||||
|
if err := runner.preflightDocDownload(context.Background(), client, endpoint, base); err == nil {
|
||||||
|
t.Fatal("network preflight failure succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootHelpRemainingCoverage(t *testing.T) {
|
||||||
|
configureRootHelp(nil)
|
||||||
|
renderRootGlobalFlags(nil)
|
||||||
|
if visiblePersistentFlags(nil) != nil || formatRootFlag(nil) != "" || commandShort(nil) != "" || visibleMCPRootCommands(nil) != nil || visibleUtilityRootCommands(nil) != nil {
|
||||||
|
t.Fatal("nil root helper contract changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
edition.Override(&edition.Hooks{VisibleProducts: func() []string { return []string{"service"} }})
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||||
|
root := &cobra.Command{Use: "root", Long: "long help"}
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.PersistentFlags().StringP("value", "x", "", "value")
|
||||||
|
root.PersistentFlags().Bool("hidden", false, "hidden")
|
||||||
|
_ = root.PersistentFlags().MarkHidden("hidden")
|
||||||
|
root.AddCommand(&cobra.Command{Use: "service", Short: "service"}, &cobra.Command{Use: "utility", Short: "utility"})
|
||||||
|
configureRootHelp(root)
|
||||||
|
if err := root.Commands()[0].Help(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root.SetArgs([]string{"help", "missing"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root.SetArgs([]string{"help", "utility"})
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := formatRootFlag(root.PersistentFlags().Lookup("value")); !strings.Contains(got, "-x") {
|
||||||
|
t.Fatalf("formatted flag = %q", got)
|
||||||
|
}
|
||||||
|
if got := formatRootFlag(root.PersistentFlags().Lookup("hidden")); !strings.Contains(got, "--hidden") {
|
||||||
|
t.Fatalf("formatted long flag = %q", got)
|
||||||
|
}
|
||||||
|
_ = commandShort(&cobra.Command{Use: "help", Short: "Help about any command"})
|
||||||
|
renderRootGlobalFlags(&cobra.Command{Use: "no-flags"})
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
SetDynamicServers(nil)
|
||||||
|
_ = visibleMCPRootCommands(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigAndTokenSeamsCoverage(t *testing.T) {
|
||||||
|
oldHome, oldExe, oldEval := userHomeDir, executablePath, evaluateSymlink
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
userHomeDir, executablePath, evaluateSymlink = oldHome, oldExe, oldEval
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
})
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", "")
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
userHomeDir = func() (string, error) { return "", errors.New("home") }
|
||||||
|
executablePath = func() (string, error) { return "", errors.New("exe") }
|
||||||
|
if got := defaultConfigDir(); got != ".dws" {
|
||||||
|
t.Fatalf("fallback config dir = %q", got)
|
||||||
|
}
|
||||||
|
executablePath = func() (string, error) { return filepath.Join("", "tmp", "dws"), nil }
|
||||||
|
evaluateSymlink = func(string) (string, error) { return "", errors.New("link") }
|
||||||
|
if got := exeRelativeConfigDir(); !strings.HasSuffix(got, filepath.Join("tmp", ".dws")) {
|
||||||
|
t.Fatalf("executable config dir = %q", got)
|
||||||
|
}
|
||||||
|
userHomeDir = func() (string, error) { return "/home/test", nil }
|
||||||
|
if got := defaultConfigDir(); got != filepath.Join("/home/test", ".dws") {
|
||||||
|
t.Fatalf("home config dir = %q", got)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{ConfigDir: func() string { return "/edition" }})
|
||||||
|
if got := defaultConfigDir(); got != "/edition" {
|
||||||
|
t.Fatalf("edition config dir = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldProvider, oldManager := newAccessTokenProvider, newLegacyTokenManager
|
||||||
|
t.Cleanup(func() { newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldManager })
|
||||||
|
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} }
|
||||||
|
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
||||||
|
t.Fatalf("decryption error = %v", err)
|
||||||
|
}
|
||||||
|
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
|
||||||
|
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
|
||||||
|
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
|
||||||
|
t.Fatalf("legacy token = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("load")} }
|
||||||
|
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{err: errors.New("missing")} }
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
other := filepath.Join(t.TempDir(), "other")
|
||||||
|
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil {
|
||||||
|
t.Fatal("auxiliary provider failure succeeded")
|
||||||
|
}
|
||||||
|
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} }
|
||||||
|
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
||||||
|
t.Fatalf("auxiliary decryption error = %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", other)
|
||||||
|
ResetRuntimeTokenCache()
|
||||||
|
if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil {
|
||||||
|
t.Fatal("current config without credentials succeeded")
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||||
|
if !strings.Contains(noCredentialsError().Error(), "认证") {
|
||||||
|
t.Fatal("embedded credentials error changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForceRefreshAndStdioFailureCoverage(t *testing.T) {
|
||||||
|
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
|
||||||
|
oldStop := stopStdio
|
||||||
|
t.Cleanup(func() {
|
||||||
|
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
|
||||||
|
stopStdio = oldStop
|
||||||
|
stdioMu.Lock()
|
||||||
|
stdioClients = make(map[string]*transport.StdioClient)
|
||||||
|
stdioMu.Unlock()
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
_ = oldFactory(t.TempDir())
|
||||||
|
markAccessTokenStale = func(string) error { return fail }
|
||||||
|
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("mark stale error = %v", err)
|
||||||
|
}
|
||||||
|
markAccessTokenStale = func(string) error { return nil }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
getter fakeAccessTokenGetter
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{getter: fakeAccessTokenGetter{err: fail}, want: "failure"},
|
||||||
|
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
|
||||||
|
{getter: fakeAccessTokenGetter{token: " refreshed "}},
|
||||||
|
} {
|
||||||
|
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
|
||||||
|
got, err := ForceRefreshAccessToken(context.Background(), "config")
|
||||||
|
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
||||||
|
t.Fatalf("refresh error = %v, want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
if tc.want == "" && (err != nil || got != "refreshed") {
|
||||||
|
t.Fatalf("refreshed token = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopStdio = func(*transport.StdioClient) error { return fail }
|
||||||
|
RegisterStdioClient("all", transport.NewStdioClient("unused", nil, nil))
|
||||||
|
StopAllStdioClients()
|
||||||
|
RegisterStdioClient("one", transport.NewStdioClient("unused", nil, nil))
|
||||||
|
if !StopStdioClient("one") {
|
||||||
|
t.Fatal("registered stdio client not stopped")
|
||||||
|
}
|
||||||
|
RegisterStdioClient("plugin/server", transport.NewStdioClient("unused", nil, nil))
|
||||||
|
if got := StopStdioClientsByPlugin("plugin"); got != 1 {
|
||||||
|
t.Fatalf("stopped plugin clients = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayRecoveryHostAndHelperRemainingCoverage(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeOverlay := filepath.Join(root, "overlay.json")
|
||||||
|
if err := os.WriteFile(writeOverlay, []byte(`{"toolOverrides":{"tool":{}}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, raw := range []json.RawMessage{
|
||||||
|
json.RawMessage(`"missing.json"`),
|
||||||
|
json.RawMessage(`"unterminated`),
|
||||||
|
json.RawMessage(`{`),
|
||||||
|
json.RawMessage(`"overlay.json"`),
|
||||||
|
json.RawMessage(`{"id":"","command":"","toolOverrides":{"tool":{}}}`),
|
||||||
|
} {
|
||||||
|
p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: raw}}}}
|
||||||
|
overlay := resolveStdioOverlay(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)})
|
||||||
|
if overlay.ID == "" || overlay.Command == "" {
|
||||||
|
t.Fatalf("overlay defaults missing: %#v", overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: json.RawMessage(`{}`)}}}}
|
||||||
|
if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server"}); descriptor.Endpoint == "" {
|
||||||
|
t.Fatalf("empty overlay descriptor = %#v", descriptor)
|
||||||
|
}
|
||||||
|
p.Manifest.MCPServers["server"].CLI = json.RawMessage(`{"toolOverrides":{"tool":{}}}`)
|
||||||
|
if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)}); descriptor.Endpoint == "" {
|
||||||
|
t.Fatalf("stdio overlay registration = %#v", descriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||||
|
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||||
|
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||||
|
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
|
||||||
|
oldArgs := os.Args
|
||||||
|
os.Args = []string{"dws", "doc", "download", "--node", "n"}
|
||||||
|
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
|
||||||
|
t.Fatalf("runtime command path = %#v", got)
|
||||||
|
}
|
||||||
|
os.Args = oldArgs
|
||||||
|
|
||||||
|
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||||
|
if hostControlProviderFromEnv() != "" {
|
||||||
|
t.Fatal("host control enabled without agent code")
|
||||||
|
}
|
||||||
|
t.Setenv(authpkg.AgentCodeEnv, "agent")
|
||||||
|
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
|
||||||
|
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
|
||||||
|
t.Fatalf("default claw type = %q", got)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{MergeHeaders: func(map[string]string) map[string]string { return map[string]string{"claw-type": "custom"} }})
|
||||||
|
if got := effectiveClawType(); got != "custom" {
|
||||||
|
t.Fatalf("custom claw type = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||||
|
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
||||||
|
command.SetOut(io.Discard)
|
||||||
|
if err := command.RunE(command, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", "configured")
|
||||||
|
configCmd := &cobra.Command{Use: "config"}
|
||||||
|
var configOut bytes.Buffer
|
||||||
|
configCmd.SetOut(&configOut)
|
||||||
|
if err := writeConfigJSON(configCmd, filterVisible(nil), true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list := newConfigListCommand()
|
||||||
|
list.SetOut(io.Discard)
|
||||||
|
_ = list.Flags().Set("category", "core")
|
||||||
|
_ = list.Flags().Set("show-values", "true")
|
||||||
|
_ = list.Flags().Set("show-hidden", "true")
|
||||||
|
_ = list.Flags().Set("json", "true")
|
||||||
|
if err := runConfigList(list, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheRoot := &cobra.Command{Use: "root"}
|
||||||
|
cacheRoot.PersistentFlags().String("format", "", "")
|
||||||
|
cacheCmd := &cobra.Command{Use: "cache"}
|
||||||
|
cacheRoot.AddCommand(cacheCmd)
|
||||||
|
for _, format := range []string{"json", "pretty", "table"} {
|
||||||
|
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||||
|
cacheCmd.SetOut(io.Discard)
|
||||||
|
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail := errors.New("write")
|
||||||
|
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||||
|
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
||||||
|
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("pretty write error = %v", err)
|
||||||
|
}
|
||||||
|
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||||
|
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("table write error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIAndTimingRemainingCoverage(t *testing.T) {
|
||||||
|
oldProvider := newAppTokenProvider
|
||||||
|
oldClientID, oldClientSecret := apiClientID, apiClientSecret
|
||||||
|
oldMarshal, oldMkdir := timingMarshalIndent, timingMkdirAll
|
||||||
|
oldWrite, oldRemove, oldRename := timingWriteFile, timingRemove, timingRename
|
||||||
|
oldRead, oldHome := timingReadFile, timingUserHomeDir
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newAppTokenProvider = oldProvider
|
||||||
|
apiClientID, apiClientSecret = oldClientID, oldClientSecret
|
||||||
|
timingMarshalIndent, timingMkdirAll = oldMarshal, oldMkdir
|
||||||
|
timingWriteFile, timingRemove, timingRename = oldWrite, oldRemove, oldRename
|
||||||
|
timingReadFile, timingUserHomeDir = oldRead, oldHome
|
||||||
|
authpkg.SetClientID("")
|
||||||
|
authpkg.SetClientSecret("")
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
apiClientID = func() string { return "" }
|
||||||
|
apiClientSecret = func() string { return "" }
|
||||||
|
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("missing raw API credentials succeeded")
|
||||||
|
}
|
||||||
|
apiClientID = func() string { return "<placeholder>" }
|
||||||
|
apiClientSecret = func() string { return "secret" }
|
||||||
|
if _, err := resolveRawAPIToken(context.Background(), ""); err == nil {
|
||||||
|
t.Fatal("placeholder raw API credentials succeeded")
|
||||||
|
}
|
||||||
|
apiClientID, apiClientSecret = authpkg.ClientID, authpkg.ClientSecret
|
||||||
|
authpkg.SetClientID("app-key")
|
||||||
|
authpkg.SetClientSecret("app-secret")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
getter fakeAppTokenGetter
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{getter: fakeAppTokenGetter{err: fail}, want: "failure"},
|
||||||
|
{getter: fakeAppTokenGetter{token: " "}, want: "为空"},
|
||||||
|
{getter: fakeAppTokenGetter{token: " token "}},
|
||||||
|
} {
|
||||||
|
newAppTokenProvider = func(string, string, string) appTokenGetter { return tc.getter }
|
||||||
|
got, err := resolveRawAPIToken(context.Background(), "")
|
||||||
|
if tc.want != "" && (err == nil || !containsText(err.Error(), tc.want)) {
|
||||||
|
t.Fatalf("raw token error = %v, want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
if tc.want == "" && (err != nil || got != "token") {
|
||||||
|
t.Fatalf("raw token = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
endpoint := server.URL
|
||||||
|
server.Close()
|
||||||
|
apiclient.AllowedHosts["127.0.0.1"] = true
|
||||||
|
t.Cleanup(func() { delete(apiclient.AllowedHosts, "127.0.0.1") })
|
||||||
|
cmd := &cobra.Command{Use: "api"}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
if err := runAPI(cmd, []string{"GET", "/path"}, &GlobalFlags{Token: "token", Timeout: 1}, &apiFlags{baseURL: endpoint}); err == nil {
|
||||||
|
t.Fatal("closed raw API endpoint succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewTimingCollector()
|
||||||
|
collector.Print(io.Discard)
|
||||||
|
t.Setenv(PerfReportEnv, t.TempDir()+"/report.json")
|
||||||
|
timingMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail }
|
||||||
|
collector.WriteReportIfEnabled("v", "cmd")
|
||||||
|
timingMarshalIndent = oldMarshal
|
||||||
|
timingUserHomeDir = func() (string, error) { return "", fail }
|
||||||
|
t.Setenv(PerfReportEnv, "auto")
|
||||||
|
collector.WriteReportIfEnabled("v", "cmd")
|
||||||
|
if defaultPerfReportPath() != "" {
|
||||||
|
t.Fatal("home-dir failure produced a report path")
|
||||||
|
}
|
||||||
|
t.Setenv(PerfReportEnv, t.TempDir()+"/report.json")
|
||||||
|
timingMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
collector.WriteReportIfEnabled("v", "cmd")
|
||||||
|
timingMkdirAll = oldMkdir
|
||||||
|
timingWriteFile = func(string, []byte, os.FileMode) error { return fail }
|
||||||
|
removed := false
|
||||||
|
timingRemove = func(string) error { removed = true; return nil }
|
||||||
|
collector.WriteReportIfEnabled("v", "cmd")
|
||||||
|
if !removed {
|
||||||
|
t.Fatal("failed temporary report was not removed")
|
||||||
|
}
|
||||||
|
timingWriteFile = oldWrite
|
||||||
|
timingRemove = oldRemove
|
||||||
|
renamed := false
|
||||||
|
timingRename = func(string, string) error { renamed = true; return fail }
|
||||||
|
collector.WriteReportIfEnabled("v", "cmd")
|
||||||
|
if !renamed {
|
||||||
|
t.Fatal("report rename was not attempted")
|
||||||
|
}
|
||||||
|
timingReadFile = func(string) ([]byte, error) { return []byte("{"), nil }
|
||||||
|
if _, err := LoadLatestReport(); err == nil {
|
||||||
|
t.Fatal("malformed performance report succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
SetDynamicServers(nil)
|
||||||
|
})
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||||
|
for _, raw := range []string{
|
||||||
|
"not a url",
|
||||||
|
"https://mcp.dingtalk.com/path?q=1#fragment",
|
||||||
|
"https://pre-mcp.example.test:8443/path/",
|
||||||
|
"https://mcp.example.test/path/",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := defaultPATGatewayBaseURL(); got == "" {
|
||||||
|
t.Fatalf("gateway for %q is blank", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoints := map[string]string{}
|
||||||
|
products := map[string]bool{}
|
||||||
|
aliases := map[string]string{}
|
||||||
|
tools := map[string]string{}
|
||||||
|
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools)
|
||||||
|
registerDynamicServer(mcptypes.ServerDescriptor{
|
||||||
|
Endpoint: "https://server.test",
|
||||||
|
CLI: mcptypes.CLIOverlay{
|
||||||
|
ID: "id", Command: "command", Aliases: []string{"alias", " "},
|
||||||
|
Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
|
||||||
|
ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}},
|
||||||
|
},
|
||||||
|
}, endpoints, products, aliases, tools)
|
||||||
|
if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" {
|
||||||
|
t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDynamicServers(nil)
|
||||||
|
dynamicMu.Lock()
|
||||||
|
dynamicEndpoints = map[string]string{}
|
||||||
|
dynamicProducts = map[string]bool{}
|
||||||
|
dynamicAliases = map[string]string{}
|
||||||
|
dynamicToolEndpoints = map[string]string{}
|
||||||
|
dynamicMu.Unlock()
|
||||||
|
if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" {
|
||||||
|
t.Fatal("cold-start PAT fallback did not resolve")
|
||||||
|
}
|
||||||
|
SetDynamicServers(nil)
|
||||||
|
if _, ok := directRuntimeEndpoint(" ", " "); ok {
|
||||||
|
t.Fatal("blank runtime endpoint resolved")
|
||||||
|
}
|
||||||
|
t.Setenv("DINGTALK_CUSTOM_MCP_URL", "https://override.test")
|
||||||
|
if got, ok := directRuntimeEndpoint("custom", ""); !ok || got != "https://override.test" {
|
||||||
|
t.Fatalf("environment runtime endpoint = %q, %v", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := directRuntimeEndpoint(devappProductID, ""); !ok || got == "" {
|
||||||
|
t.Fatal("devapp fallback did not resolve")
|
||||||
|
}
|
||||||
|
if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" {
|
||||||
|
t.Fatal("PAT fallback did not resolve")
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
StaticServers: func() []edition.ServerInfo { return []edition.ServerInfo{{ID: "other", Endpoint: ""}} },
|
||||||
|
SupplementServers: func() []edition.ServerInfo {
|
||||||
|
return []edition.ServerInfo{{ID: "other", Endpoint: "https://other.test", Prefixes: []string{" ", "wanted"}}}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got, ok := directRuntimeEndpoint("wanted", ""); !ok || got != "https://other.test" {
|
||||||
|
t.Fatalf("edition runtime endpoint = %q, %v", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicMu.Lock()
|
||||||
|
dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints = nil, nil, nil, nil
|
||||||
|
dynamicMu.Unlock()
|
||||||
|
AppendDynamicServer(mcptypes.ServerDescriptor{
|
||||||
|
Endpoint: "https://append.test",
|
||||||
|
CLI: mcptypes.CLIOverlay{
|
||||||
|
ID: "append", Command: "append-command", Aliases: []string{"append-alias"},
|
||||||
|
Tools: []mcptypes.CLITool{{Name: "append-tool"}},
|
||||||
|
ToolOverrides: map[string]mcptypes.CLIToolOverride{
|
||||||
|
"append-override": {}, "skip": {ServerOverride: "other"}, " ": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" {
|
||||||
|
t.Fatalf("append override endpoint = %q, %v", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsText(value, substring string) bool {
|
||||||
|
for i := 0; i+len(substring) <= len(value); i++ {
|
||||||
|
if value[i:i+len(substring)] == substring {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedSkillAndTinyCommandsRemainingCoverage(t *testing.T) {
|
||||||
|
oldStat, oldTemp, oldRemove := embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll
|
||||||
|
oldWalk, oldRead := embeddedSkillWalkDir, embeddedSkillReadFile
|
||||||
|
oldMkdir, oldWrite := embeddedSkillMkdirAll, embeddedSkillWriteFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll = oldStat, oldTemp, oldRemove
|
||||||
|
embeddedSkillWalkDir, embeddedSkillReadFile = oldWalk, oldRead
|
||||||
|
embeddedSkillMkdirAll, embeddedSkillWriteFile = oldMkdir, oldWrite
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
embeddedSkillStat = func(string) (os.FileInfo, error) { return nil, nil }
|
||||||
|
embeddedSkillMkdirTemp = func(string, string) (string, error) { return "", fail }
|
||||||
|
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("embedded mkdir error = %v", err)
|
||||||
|
}
|
||||||
|
embeddedSkillMkdirTemp = func(string, string) (string, error) { return t.TempDir(), nil }
|
||||||
|
removed := false
|
||||||
|
embeddedSkillRemoveAll = func(string) error { removed = true; return nil }
|
||||||
|
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||||
|
return fn("entry", nil, fail)
|
||||||
|
}
|
||||||
|
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) || !removed {
|
||||||
|
t.Fatalf("embedded walk error = %v, removed=%v", err, removed)
|
||||||
|
}
|
||||||
|
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||||
|
return fn("skills/codex/file", fakeSkillDirEntry{}, nil)
|
||||||
|
}
|
||||||
|
embeddedSkillReadFile = func(string) ([]byte, error) { return nil, fail }
|
||||||
|
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("embedded read error = %v", err)
|
||||||
|
}
|
||||||
|
embeddedSkillReadFile = func(string) ([]byte, error) { return []byte("skill"), nil }
|
||||||
|
embeddedSkillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("embedded nested mkdir error = %v", err)
|
||||||
|
}
|
||||||
|
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||||
|
return fn("skills/codex/dir", fakeSkillDirEntry{dir: true}, nil)
|
||||||
|
}
|
||||||
|
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("embedded directory mkdir error = %v", err)
|
||||||
|
}
|
||||||
|
embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error {
|
||||||
|
return fn("skills/codex/file", fakeSkillDirEntry{}, nil)
|
||||||
|
}
|
||||||
|
embeddedSkillMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
embeddedSkillWriteFile = func(string, []byte, os.FileMode) error { return fail }
|
||||||
|
if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("embedded write error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeTopLevelCommands([]*cobra.Command{nil, {}})
|
||||||
|
if len(merged) != 0 {
|
||||||
|
t.Fatalf("empty legacy commands = %#v", merged)
|
||||||
|
}
|
||||||
|
root := &cobra.Command{Use: "root"}
|
||||||
|
completion := newCompletionCommand(root)
|
||||||
|
if err := completion.RunE(completion, []string{"other"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog := newCatalogCommand(nil)
|
||||||
|
catalog.SetOut(io.Discard)
|
||||||
|
if err := catalog.RunE(catalog, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
AccessToken: cfg.Token,
|
AccessToken: cfg.Token,
|
||||||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||||||
}
|
}
|
||||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
if err := authSaveTokenData(configDir, tokenData); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||||||
}
|
}
|
||||||
case cfg.Device:
|
case cfg.Device:
|
||||||
@@ -146,7 +146,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||||
provider.Output = cmd.ErrOrStderr()
|
provider.Output = cmd.ErrOrStderr()
|
||||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||||
tokenData, err = provider.Login(loginCtx)
|
tokenData, err = authDeviceLogin(provider, loginCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||||
provider.TargetCorpID = cfg.TargetCorpID
|
provider.TargetCorpID = cfg.TargetCorpID
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
|
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
var initialPlan *pat.LoginRecommendPlan
|
var initialPlan *pat.LoginRecommendPlan
|
||||||
if postLoginTUIMode {
|
if postLoginTUIMode {
|
||||||
var planErr error
|
var planErr error
|
||||||
initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller)
|
initialPlan, planErr = authPlanLoginRecommend(cmd.Context(), patCaller)
|
||||||
if planErr != nil {
|
if planErr != nil {
|
||||||
return planErr
|
return planErr
|
||||||
}
|
}
|
||||||
@@ -207,11 +207,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
retryFormat = "table"
|
retryFormat = "table"
|
||||||
}
|
}
|
||||||
run := func(ctx context.Context) error {
|
run := func(ctx context.Context) error {
|
||||||
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
return authRunLoginRecommend(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||||||
}
|
}
|
||||||
err := run(cmd.Context())
|
err := run(cmd.Context())
|
||||||
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
||||||
return runDirectPATAuthCheckWaitOnly(
|
return authRunDirectPATWait(
|
||||||
cmd.Context(),
|
cmd.Context(),
|
||||||
&GlobalFlags{Format: retryFormat},
|
&GlobalFlags{Format: retryFormat},
|
||||||
patErr,
|
patErr,
|
||||||
@@ -234,11 +234,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
|
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||||||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
|
||||||
}
|
|
||||||
if tokenData != nil {
|
if tokenData != nil {
|
||||||
if tokenData.CorpName != "" {
|
if tokenData.CorpName != "" {
|
||||||
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
||||||
@@ -286,6 +282,42 @@ var (
|
|||||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||||
|
authRunForm = (*huh.Form).Run
|
||||||
|
authSaveTokenData = authpkg.SaveTokenData
|
||||||
|
authSaveAppConfig = authpkg.SaveAppConfig
|
||||||
|
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||||
|
return provider.Login(ctx)
|
||||||
|
}
|
||||||
|
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, force bool) (*authpkg.TokenData, error) {
|
||||||
|
return provider.Login(ctx, force)
|
||||||
|
}
|
||||||
|
authOAuthStatus = func(provider *authpkg.OAuthProvider) (*authpkg.TokenData, error) { return provider.Status() }
|
||||||
|
authOAuthAccessToken = func(provider *authpkg.OAuthProvider, ctx context.Context) (string, error) {
|
||||||
|
return provider.GetAccessToken(ctx)
|
||||||
|
}
|
||||||
|
authOAuthExchange = func(provider *authpkg.OAuthProvider, ctx context.Context, code, uid string) (*authpkg.TokenData, error) {
|
||||||
|
return provider.ExchangeAuthCode(ctx, code, uid)
|
||||||
|
}
|
||||||
|
authPlanLoginRecommend = pat.PlanLoginRecommendAuthorization
|
||||||
|
authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions
|
||||||
|
authRunDirectPATWait = runDirectPATAuthCheckWaitOnly
|
||||||
|
authResolveProfile = authpkg.ResolveProfile
|
||||||
|
authRevokeToken = authpkg.RevokeTokenRemote
|
||||||
|
authDeleteProfileToken = authpkg.DeleteTokenDataForProfile
|
||||||
|
authEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||||||
|
authLoadProfiles = authpkg.LoadProfiles
|
||||||
|
authDeleteAllTokenData = authpkg.DeleteAllTokenData
|
||||||
|
authDeleteTokenData = authpkg.DeleteTokenData
|
||||||
|
authMarkProfileStatus = authpkg.MarkProfileStatus
|
||||||
|
authPortableExportSupported = authpkg.PortableExportSupported
|
||||||
|
authPortableSourceReady = authpkg.PortableAuthSourceReady
|
||||||
|
authPortableTargetPopulated = authpkg.PortableAuthTargetPopulated
|
||||||
|
authExportPortableBundle = authpkg.ExportPortableAuthBundle
|
||||||
|
authImportPortableBundle = authpkg.ImportPortableAuthBundle
|
||||||
|
authAtomicWrite = helpers.AtomicWrite
|
||||||
|
authReadFile = os.ReadFile
|
||||||
|
authRemove = os.Remove
|
||||||
|
authDeleteAppConfig = authpkg.DeleteAppConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||||
@@ -302,7 +334,7 @@ func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
|||||||
Value(&choice),
|
Value(&choice),
|
||||||
),
|
),
|
||||||
).WithTheme(authLoginHuhTheme())
|
).WithTheme(authLoginHuhTheme())
|
||||||
if err := form.Run(); err != nil {
|
if err := authRunForm(form); err != nil {
|
||||||
return "", fmt.Errorf("使用引导选择中止: %w", err)
|
return "", fmt.Errorf("使用引导选择中止: %w", err)
|
||||||
}
|
}
|
||||||
return choice, nil
|
return choice, nil
|
||||||
@@ -322,7 +354,7 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
|
|||||||
}
|
}
|
||||||
authpkg.SetClientID(clientID)
|
authpkg.SetClientID(clientID)
|
||||||
authpkg.SetClientSecret(clientSecret)
|
authpkg.SetClientSecret(clientSecret)
|
||||||
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
|
if err := authSaveAppConfig(configDir, &authpkg.AppConfig{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: authpkg.PlainSecret(clientSecret),
|
ClientSecret: authpkg.PlainSecret(clientSecret),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -336,33 +368,34 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth
|
|||||||
|
|
||||||
func promptAuthLoginManualCredentials() (string, string, error) {
|
func promptAuthLoginManualCredentials() (string, string, error) {
|
||||||
var clientID, clientSecret string
|
var clientID, clientSecret string
|
||||||
nonEmpty := func(label string) func(string) error {
|
|
||||||
return func(value string) error {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return fmt.Errorf("%s 不能为空", label)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("输入 AppKey").
|
Title("输入 AppKey").
|
||||||
Value(&clientID).
|
Value(&clientID).
|
||||||
Validate(nonEmpty("AppKey")),
|
Validate(authLoginNonEmpty("AppKey")),
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("输入 AppSecret").
|
Title("输入 AppSecret").
|
||||||
EchoMode(huh.EchoModePassword).
|
EchoMode(huh.EchoModePassword).
|
||||||
Value(&clientSecret).
|
Value(&clientSecret).
|
||||||
Validate(nonEmpty("AppSecret")),
|
Validate(authLoginNonEmpty("AppSecret")),
|
||||||
),
|
),
|
||||||
).WithTheme(authLoginHuhTheme())
|
).WithTheme(authLoginHuhTheme())
|
||||||
if err := form.Run(); err != nil {
|
if err := authRunForm(form); err != nil {
|
||||||
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
|
return "", "", fmt.Errorf("应用凭证输入中止: %w", err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
|
return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginNonEmpty(label string) func(string) error {
|
||||||
|
return func(value string) error {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("%s 不能为空", label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
||||||
choice := pat.LoginRecommendScopeRecommended
|
choice := pat.LoginRecommendScopeRecommended
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
@@ -377,7 +410,7 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
|
|||||||
Value(&choice),
|
Value(&choice),
|
||||||
),
|
),
|
||||||
).WithTheme(authLoginHuhTheme())
|
).WithTheme(authLoginHuhTheme())
|
||||||
if err := form.Run(); err != nil {
|
if err := authRunForm(form); err != nil {
|
||||||
return "", fmt.Errorf("授权范围选择中止: %w", err)
|
return "", fmt.Errorf("授权范围选择中止: %w", err)
|
||||||
}
|
}
|
||||||
return choice, nil
|
return choice, nil
|
||||||
@@ -452,21 +485,21 @@ func newAuthStatusCommand() *cobra.Command {
|
|||||||
var statusErr error
|
var statusErr error
|
||||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
if data, err := provider.Status(); err == nil {
|
if data, err := authOAuthStatus(provider); err == nil {
|
||||||
tokenData = data
|
tokenData = data
|
||||||
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
|
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
|
||||||
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
_, refreshErr := authOAuthAccessToken(provider, refreshCtx)
|
||||||
cancel()
|
cancel()
|
||||||
if refreshErr == nil {
|
if refreshErr == nil {
|
||||||
if updatedData, statusErr := provider.Status(); statusErr == nil {
|
if updatedData, statusErr := authOAuthStatus(provider); statusErr == nil {
|
||||||
tokenData = updatedData
|
tokenData = updatedData
|
||||||
refreshed = true
|
refreshed = true
|
||||||
}
|
}
|
||||||
} else if edition.Get().AutoPurgeToken {
|
} else if edition.Get().AutoPurgeToken {
|
||||||
_ = authpkg.DeleteTokenData(configDir)
|
_ = authDeleteTokenData(configDir)
|
||||||
} else if tokenData != nil {
|
} else if tokenData != nil {
|
||||||
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
_ = authMarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if authStatusAuthenticated(tokenData) {
|
if authStatusAuthenticated(tokenData) {
|
||||||
@@ -584,36 +617,36 @@ func newAuthMigrateKeychainCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
if _, err := authResolveProfile(configDir, selector); err != nil {
|
||||||
return apperrors.NewValidation(err.Error())
|
return apperrors.NewValidation(err.Error())
|
||||||
}
|
}
|
||||||
restoreProfile := pushRuntimeProfile(selector)
|
restoreProfile := pushRuntimeProfile(selector)
|
||||||
defer restoreProfile()
|
defer restoreProfile()
|
||||||
_ = authpkg.RevokeTokenRemote(ctx)
|
_ = authRevokeToken(ctx)
|
||||||
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
|
if err := authDeleteProfileToken(configDir, selector); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
|
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
|
||||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
if err := authEnsureProfilesMigration(configDir); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||||
}
|
}
|
||||||
cfg, err := authpkg.LoadProfiles(configDir)
|
cfg, err := authLoadProfiles(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||||
}
|
}
|
||||||
if cfg == nil || len(cfg.Profiles) == 0 {
|
if cfg == nil || len(cfg.Profiles) == 0 {
|
||||||
_ = authpkg.RevokeTokenRemote(ctx)
|
_ = authRevokeToken(ctx)
|
||||||
} else {
|
} else {
|
||||||
for _, profile := range cfg.Profiles {
|
for _, profile := range cfg.Profiles {
|
||||||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||||||
_ = authpkg.RevokeTokenRemote(ctx)
|
_ = authRevokeToken(ctx)
|
||||||
restoreProfile()
|
restoreProfile()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
if err := authDeleteAllTokenData(configDir); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -656,18 +689,18 @@ func newAuthExportCommand() *cobra.Command {
|
|||||||
if !asBase64 && output == "" {
|
if !asBase64 && output == "" {
|
||||||
return apperrors.NewValidation("--output is required unless --base64 is used")
|
return apperrors.NewValidation("--output is required unless --base64 is used")
|
||||||
}
|
}
|
||||||
if !authpkg.PortableExportSupported() {
|
if !authPortableExportSupported() {
|
||||||
return apperrors.NewValidation(fmt.Sprintf(
|
return apperrors.NewValidation(fmt.Sprintf(
|
||||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||||
keychain.DisableKeychainEnv,
|
keychain.DisableKeychainEnv,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
if !authpkg.PortableAuthSourceReady() {
|
if !authPortableSourceReady() {
|
||||||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
||||||
}
|
}
|
||||||
|
|
||||||
var bundle bytes.Buffer
|
var bundle bytes.Buffer
|
||||||
if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil {
|
if err := authExportPortableBundle(defaultConfigDir(), &bundle); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +710,7 @@ func newAuthExportCommand() *cobra.Command {
|
|||||||
_, err := cmd.OutOrStdout().Write(payload)
|
_, err := cmd.OutOrStdout().Write(payload)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil {
|
if err := authAtomicWrite(output, payload, config.FilePerm); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||||||
@@ -685,7 +718,7 @@ func newAuthExportCommand() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
if err := authAtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||||||
@@ -729,11 +762,11 @@ func newAuthImportCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
if !force && authpkg.PortableAuthTargetPopulated(configDir) {
|
if !force && authPortableTargetPopulated(configDir) {
|
||||||
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := os.ReadFile(input)
|
payload, err := authReadFile(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
|
||||||
}
|
}
|
||||||
@@ -743,7 +776,7 @@ func newAuthImportCommand() *cobra.Command {
|
|||||||
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
|
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload))
|
report, err := authImportPortableBundle(configDir, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
|
||||||
}
|
}
|
||||||
@@ -788,7 +821,7 @@ func newAuthExchangeCommand() *cobra.Command {
|
|||||||
configureOAuthProviderCompatibility(provider, configDir)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
|
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
tokenData, err := provider.ExchangeAuthCode(exchangeCtx, code, strings.TrimSpace(uid))
|
tokenData, err := authOAuthExchange(provider, exchangeCtx, code, strings.TrimSpace(uid))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
||||||
}
|
}
|
||||||
@@ -827,12 +860,12 @@ func newAuthResetCommand() *cobra.Command {
|
|||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
if err := authDeleteAllTokenData(configDir); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||||
}
|
}
|
||||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
_ = authRemove(filepath.Join(configDir, "mcp_url"))
|
||||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
_ = authRemove(filepath.Join(configDir, "token"))
|
||||||
_ = authpkg.DeleteAppConfig(configDir)
|
_ = authDeleteAppConfig(configDir)
|
||||||
ResetRuntimeTokenCache()
|
ResetRuntimeTokenCache()
|
||||||
clearCompatCache()
|
clearCompatCache()
|
||||||
w := cmd.OutOrStdout()
|
w := cmd.OutOrStdout()
|
||||||
@@ -908,20 +941,22 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin
|
|||||||
Options(options...).
|
Options(options...).
|
||||||
Height(height).
|
Height(height).
|
||||||
Value(&selected).
|
Value(&selected).
|
||||||
Validate(func(values []string) error {
|
Validate(authLoginProductsNonEmpty),
|
||||||
if len(values) == 0 {
|
|
||||||
return fmt.Errorf("至少选择一个授权业务域")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
).WithTheme(authLoginHuhTheme())
|
).WithTheme(authLoginHuhTheme())
|
||||||
if err := form.Run(); err != nil {
|
if err := authRunForm(form); err != nil {
|
||||||
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
||||||
}
|
}
|
||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginProductsNonEmpty(values []string) error {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return fmt.Errorf("至少选择一个授权业务域")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func authLoginHuhTheme() *huh.Theme {
|
func authLoginHuhTheme() *huh.Theme {
|
||||||
t := huh.ThemeBase()
|
t := huh.ThemeBase()
|
||||||
|
|
||||||
@@ -1124,7 +1159,7 @@ func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
|||||||
if selector == "" {
|
if selector == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
|
if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||||
return strings.TrimSpace(profile.CorpID), nil
|
return strings.TrimSpace(profile.CorpID), nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(selector, "ding") {
|
if strings.HasPrefix(selector, "ding") {
|
||||||
@@ -1183,7 +1218,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca
|
|||||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
|
if err := authSaveTokenData(configDir, &updated); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
*data = updated
|
*data = updated
|
||||||
|
|||||||
@@ -0,0 +1,788 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authCoverageCaller struct {
|
||||||
|
result *edition.ToolResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *authCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||||
|
return c.result, c.err
|
||||||
|
}
|
||||||
|
func (*authCoverageCaller) Format() string { return "json" }
|
||||||
|
func (*authCoverageCaller) DryRun() bool { return false }
|
||||||
|
func (*authCoverageCaller) Fields() string { return "" }
|
||||||
|
func (*authCoverageCaller) JQ() string { return "" }
|
||||||
|
|
||||||
|
func authCoverageRoot(child *cobra.Command, format string, yes bool) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||||
|
root := &cobra.Command{Use: "dws"}
|
||||||
|
root.SetContext(context.Background())
|
||||||
|
child.SetContext(context.Background())
|
||||||
|
root.PersistentFlags().String("format", format, "")
|
||||||
|
root.PersistentFlags().Bool("yes", yes, "")
|
||||||
|
root.PersistentFlags().String("profile", "", "")
|
||||||
|
root.AddCommand(child)
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
errOut := &bytes.Buffer{}
|
||||||
|
root.SetOut(out)
|
||||||
|
root.SetErr(errOut)
|
||||||
|
return root, out, errOut
|
||||||
|
}
|
||||||
|
|
||||||
|
func authCoverageRunLogin(t *testing.T, caller edition.ToolCaller, format string, yes bool, flags map[string]string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newAuthLoginCommand(caller)
|
||||||
|
root, out, errOut := authCoverageRoot(cmd, format, yes)
|
||||||
|
for name, value := range flags {
|
||||||
|
flagSet := cmd.Flags()
|
||||||
|
if root.PersistentFlags().Lookup(name) != nil {
|
||||||
|
flagSet = root.PersistentFlags()
|
||||||
|
}
|
||||||
|
if err := flagSet.Set(name, value); err != nil {
|
||||||
|
t.Fatalf("set %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := cmd.RunE(cmd, nil)
|
||||||
|
return out.String(), errOut.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldRunForm := authRunForm
|
||||||
|
oldPrompt := authLoginManualCredentialsPrompt
|
||||||
|
oldSaveConfig := authSaveAppConfig
|
||||||
|
oldResolve := authResolveProfile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
authRunForm = oldRunForm
|
||||||
|
authLoginManualCredentialsPrompt = oldPrompt
|
||||||
|
authSaveAppConfig = oldSaveConfig
|
||||||
|
authResolveProfile = oldResolve
|
||||||
|
})
|
||||||
|
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
parent := buildAuthCommand(nil)
|
||||||
|
if parent.CommandPath() == "" || parent.RunE(parent, nil) != nil {
|
||||||
|
t.Fatal("auth parent should render help")
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{HideAuthLogin: true})
|
||||||
|
if got := buildAuthCommand(nil).Commands(); len(got) != 7 {
|
||||||
|
t.Fatalf("hidden-login subcommands = %d, want 7", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
authRunForm = func(*huh.Form) error { return nil }
|
||||||
|
if choice, err := selectAuthLoginGuideAction(); err != nil || choice != authLoginGuideDirectCLI {
|
||||||
|
t.Fatalf("guide choice = %q, %v", choice, err)
|
||||||
|
}
|
||||||
|
if id, secret, err := promptAuthLoginManualCredentials(); err != nil || id != "" || secret != "" {
|
||||||
|
t.Fatalf("manual prompt = %q/%q, %v", id, secret, err)
|
||||||
|
}
|
||||||
|
if mode, err := selectLoginRecommendScopeMode(); err != nil || mode != pat.LoginRecommendScopeRecommended {
|
||||||
|
t.Fatalf("scope mode = %q, %v", mode, err)
|
||||||
|
}
|
||||||
|
products := []pat.LoginRecommendProduct{{ProductCode: "doc"}, {ProductCode: ""}}
|
||||||
|
if selected, err := selectLoginRecommendProducts(products); err != nil || len(selected) != 1 || selected[0] != "doc" {
|
||||||
|
t.Fatalf("selected products = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
if selected, err := selectLoginRecommendProducts(nil); err != nil || selected != nil {
|
||||||
|
t.Fatalf("empty selected products = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
if selected, err := selectLoginRecommendProducts([]pat.LoginRecommendProduct{{}}); err != nil || selected != nil {
|
||||||
|
t.Fatalf("blank selected products = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
many := make([]pat.LoginRecommendProduct, 16)
|
||||||
|
for i := range many {
|
||||||
|
many[i].ProductCode = fmt.Sprintf("p%d", i)
|
||||||
|
}
|
||||||
|
if selected, err := selectLoginRecommendProducts(many); err != nil || len(selected) != 16 {
|
||||||
|
t.Fatalf("many selected products = %d, %v", len(selected), err)
|
||||||
|
}
|
||||||
|
if authLoginProductsNonEmpty(nil) == nil || authLoginProductsNonEmpty([]string{"doc"}) != nil {
|
||||||
|
t.Fatal("product validator mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
authRunForm = func(*huh.Form) error { return errors.New("cancel") }
|
||||||
|
if _, err := selectAuthLoginGuideAction(); err == nil {
|
||||||
|
t.Fatal("guide cancellation should fail")
|
||||||
|
}
|
||||||
|
if _, _, err := promptAuthLoginManualCredentials(); err == nil {
|
||||||
|
t.Fatal("credential cancellation should fail")
|
||||||
|
}
|
||||||
|
if _, err := selectLoginRecommendScopeMode(); err == nil {
|
||||||
|
t.Fatal("scope cancellation should fail")
|
||||||
|
}
|
||||||
|
if _, err := selectLoginRecommendProducts(products); err == nil {
|
||||||
|
t.Fatal("product cancellation should fail")
|
||||||
|
}
|
||||||
|
if authLoginNonEmpty("field")(" ") == nil || authLoginNonEmpty("field")("value") != nil {
|
||||||
|
t.Fatal("non-empty validator mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideDirectCLI); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideConfigureAgentApp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), "unknown"); err == nil {
|
||||||
|
t.Fatal("unknown guide action should fail")
|
||||||
|
}
|
||||||
|
authLoginManualCredentialsPrompt = func() (string, string, error) { return "", "", errors.New("cancel") }
|
||||||
|
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
|
||||||
|
t.Fatal("manual prompt error should propagate")
|
||||||
|
}
|
||||||
|
authLoginManualCredentialsPrompt = func() (string, string, error) { return "id", "secret", nil }
|
||||||
|
authSaveAppConfig = func(string, *authpkg.AppConfig) error { return errors.New("save") }
|
||||||
|
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil {
|
||||||
|
t.Fatal("app-config save error should propagate")
|
||||||
|
}
|
||||||
|
authSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
|
||||||
|
if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authResolveProfile = func(string, string) (*authpkg.Profile, error) {
|
||||||
|
return &authpkg.Profile{CorpID: " ding-profile "}, nil
|
||||||
|
}
|
||||||
|
if got, err := resolveAuthLoginTargetCorpID("cfg", "name"); err != nil || got != "ding-profile" {
|
||||||
|
t.Fatalf("resolved target = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||||
|
for selector, want := range map[string]struct {
|
||||||
|
value string
|
||||||
|
err bool
|
||||||
|
}{"": {"", false}, "ding-direct": {"ding-direct", false}, "other": {"", true}} {
|
||||||
|
got, err := resolveAuthLoginTargetCorpID("cfg", selector)
|
||||||
|
if got != want.value || (err != nil) != want.err {
|
||||||
|
t.Fatalf("target %q = %q, %v", selector, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badToken := &cobra.Command{}
|
||||||
|
badToken.Flags().Bool("token", false, "")
|
||||||
|
if _, err := resolveAuthLoginConfig(badToken); err == nil {
|
||||||
|
t.Fatal("invalid token flag should fail")
|
||||||
|
}
|
||||||
|
badDevice := &cobra.Command{}
|
||||||
|
badDevice.Flags().String("token", "", "")
|
||||||
|
badDevice.Flags().String("device", "", "")
|
||||||
|
if _, err := resolveAuthLoginConfig(badDevice); err == nil {
|
||||||
|
t.Fatal("invalid device flag should fail")
|
||||||
|
}
|
||||||
|
badForce := &cobra.Command{}
|
||||||
|
badForce.Flags().String("token", "", "")
|
||||||
|
badForce.Flags().Bool("device", false, "")
|
||||||
|
badForce.Flags().String("force", "", "")
|
||||||
|
if _, err := resolveAuthLoginConfig(badForce); err == nil {
|
||||||
|
t.Fatal("invalid force flag should fail")
|
||||||
|
}
|
||||||
|
badRecommend := &cobra.Command{}
|
||||||
|
badRecommend.Flags().String("token", "", "")
|
||||||
|
badRecommend.Flags().Bool("device", false, "")
|
||||||
|
badRecommend.Flags().Bool("force", false, "")
|
||||||
|
badRecommend.Flags().String("recommend", "", "")
|
||||||
|
if _, err := resolveAuthLoginConfig(badRecommend); err == nil {
|
||||||
|
t.Fatal("invalid recommend flag should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCoverageLoginFlows(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
oldSave := authSaveTokenData
|
||||||
|
oldDevice := authDeviceLogin
|
||||||
|
oldOAuth := authOAuthLogin
|
||||||
|
oldRunRecommend := authRunLoginRecommend
|
||||||
|
oldRunWait := authRunDirectPATWait
|
||||||
|
oldPlan := authPlanLoginRecommend
|
||||||
|
oldScope := loginRecommendScopeModeSelector
|
||||||
|
oldProducts := loginRecommendProductSelector
|
||||||
|
oldInteractive := authLoginInteractiveTerminal
|
||||||
|
oldResolve := authResolveProfile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
authSaveTokenData = oldSave
|
||||||
|
authDeviceLogin = oldDevice
|
||||||
|
authOAuthLogin = oldOAuth
|
||||||
|
authRunLoginRecommend = oldRunRecommend
|
||||||
|
authRunDirectPATWait = oldRunWait
|
||||||
|
authPlanLoginRecommend = oldPlan
|
||||||
|
loginRecommendScopeModeSelector = oldScope
|
||||||
|
loginRecommendProductSelector = oldProducts
|
||||||
|
authLoginInteractiveTerminal = oldInteractive
|
||||||
|
authResolveProfile = oldResolve
|
||||||
|
})
|
||||||
|
authInteractiveFalse := func() bool { return false }
|
||||||
|
authLoginInteractiveTerminal = authInteractiveFalse
|
||||||
|
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"profile": "bad"}); err == nil {
|
||||||
|
t.Fatal("invalid login profile should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token"}); err == nil {
|
||||||
|
t.Fatal("token save should fail")
|
||||||
|
}
|
||||||
|
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
|
||||||
|
if out, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": " token "}); err != nil || !strings.Contains(out, "登录成功") {
|
||||||
|
t.Fatalf("token login = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
|
||||||
|
t.Fatalf("json token login = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||||
|
return nil, errors.New("device")
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
|
||||||
|
t.Fatal("device error should propagate")
|
||||||
|
}
|
||||||
|
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||||
|
return nil, errors.New("oauth")
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
|
||||||
|
t.Fatal("oauth error should propagate")
|
||||||
|
}
|
||||||
|
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{
|
||||||
|
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
|
||||||
|
CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{}`}}}}
|
||||||
|
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
|
||||||
|
t.Fatalf("oauth success = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||||
|
return errors.New("recommend")
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "x", "recommend": "true"}); err == nil {
|
||||||
|
t.Fatal("recommend error should propagate")
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err == nil {
|
||||||
|
t.Fatal("JSON recommend error should propagate")
|
||||||
|
}
|
||||||
|
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||||
|
return &apperrors.PATError{RawJSON: `{"code":"PAT_SCOPE_AUTH_REQUIRED"}`}
|
||||||
|
}
|
||||||
|
waited := false
|
||||||
|
authRunDirectPATWait = func(context.Context, *GlobalFlags, *apperrors.PATError, io.Writer) error {
|
||||||
|
waited = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err != nil || !waited {
|
||||||
|
t.Fatalf("PAT wait = %v, waited=%v", err, waited)
|
||||||
|
}
|
||||||
|
|
||||||
|
authLoginInteractiveTerminal = func() bool { return true }
|
||||||
|
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
|
||||||
|
return nil, errors.New("plan")
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil {
|
||||||
|
t.Fatal("plan error should propagate")
|
||||||
|
}
|
||||||
|
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
|
||||||
|
return &pat.LoginRecommendPlan{AllGranted: true}, nil
|
||||||
|
}
|
||||||
|
if _, stderr, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !strings.Contains(stderr, "全部授权") {
|
||||||
|
t.Fatalf("all-granted plan = %q, %v", stderr, err)
|
||||||
|
}
|
||||||
|
authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) {
|
||||||
|
return &pat.LoginRecommendPlan{Scopes: []string{"scope"}, Products: []pat.LoginRecommendProduct{{ProductCode: "doc"}}}, nil
|
||||||
|
}
|
||||||
|
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return "", errors.New("scope") }
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil {
|
||||||
|
t.Fatal("scope selector error should propagate")
|
||||||
|
}
|
||||||
|
loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return pat.LoginRecommendScopeAll, nil }
|
||||||
|
loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { return []string{"doc"}, nil }
|
||||||
|
selected := false
|
||||||
|
authRunLoginRecommend = func(_ context.Context, _ edition.ToolCaller, _ io.Writer, opts pat.LoginRecommendOptions) error {
|
||||||
|
if opts.ProductSelector != nil {
|
||||||
|
_, err := opts.ProductSelector(opts.InitialPlan.Products)
|
||||||
|
selected = err == nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !selected {
|
||||||
|
t.Fatalf("interactive recommendation = %v, selected=%v", err, selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCoverageContactEnrichment(t *testing.T) {
|
||||||
|
oldSave := authSaveTokenData
|
||||||
|
t.Cleanup(func() { authSaveTokenData = oldSave })
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, &authpkg.TokenData{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
complete := &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u", UserName: "User"}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||||
|
t.Fatal("caller error should propagate")
|
||||||
|
}
|
||||||
|
for _, text := range []string{"", "not-json", `{"result":[]}`, `{"result":[{"orgEmployeeModel":{}}]}`} {
|
||||||
|
caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: text}}}}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding"}); err != nil {
|
||||||
|
t.Fatalf("invalid contact %q: %v", text, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||||
|
t.Fatal("corp mismatch should fail")
|
||||||
|
}
|
||||||
|
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unchangedPartial := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u"}}]}`}}}}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", unchangedPartial, &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") }
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||||
|
t.Fatal("enrichment save should fail")
|
||||||
|
}
|
||||||
|
authSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
|
||||||
|
data := &authpkg.TokenData{CorpID: "ding"}
|
||||||
|
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
|
||||||
|
t.Fatalf("enriched = %#v, %v", data, err)
|
||||||
|
}
|
||||||
|
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
|
||||||
|
t.Fatal("nil result should not parse")
|
||||||
|
}
|
||||||
|
if got := firstNonEmptyString(" ", " value ", "later"); got != "value" {
|
||||||
|
t.Fatalf("first non-empty = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCoverageDefaultSeamClosures(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
configDir := t.TempDir()
|
||||||
|
device := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||||
|
_, _ = authDeviceLogin(device, ctx)
|
||||||
|
oauth := authpkg.NewOAuthProvider(configDir, nil)
|
||||||
|
_, _ = authOAuthStatus(oauth)
|
||||||
|
_, _ = authOAuthAccessToken(oauth, ctx)
|
||||||
|
_, _ = authOAuthLogin(oauth, ctx, true)
|
||||||
|
_, _ = authOAuthExchange(oauth, ctx, "code", "uid")
|
||||||
|
_ = fmt.Sprintf("%v", os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCoverageStatusAndLogout(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldStatus := authOAuthStatus
|
||||||
|
oldAccess := authOAuthAccessToken
|
||||||
|
oldDelete := authDeleteTokenData
|
||||||
|
oldMark := authMarkProfileStatus
|
||||||
|
oldResolve := authResolveProfile
|
||||||
|
oldRevoke := authRevokeToken
|
||||||
|
oldDeleteProfile := authDeleteProfileToken
|
||||||
|
oldMigrate := authEnsureProfilesMigration
|
||||||
|
oldLoadProfiles := authLoadProfiles
|
||||||
|
oldDeleteAll := authDeleteAllTokenData
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
authOAuthStatus = oldStatus
|
||||||
|
authOAuthAccessToken = oldAccess
|
||||||
|
authDeleteTokenData = oldDelete
|
||||||
|
authMarkProfileStatus = oldMark
|
||||||
|
authResolveProfile = oldResolve
|
||||||
|
authRevokeToken = oldRevoke
|
||||||
|
authDeleteProfileToken = oldDeleteProfile
|
||||||
|
authEnsureProfilesMigration = oldMigrate
|
||||||
|
authLoadProfiles = oldLoadProfiles
|
||||||
|
authDeleteAllTokenData = oldDeleteAll
|
||||||
|
})
|
||||||
|
|
||||||
|
badStatus := newAuthStatusCommand()
|
||||||
|
bad := &cobra.Command{}
|
||||||
|
bad.Flags().Bool("profile", false, "")
|
||||||
|
if err := badStatus.RunE(bad, nil); err == nil {
|
||||||
|
t.Fatal("invalid profile flag should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
runStatus := func(format string) (string, error) {
|
||||||
|
cmd := newAuthStatusCommand()
|
||||||
|
_, out, _ := authCoverageRoot(cmd, format, false)
|
||||||
|
err := cmd.RunE(cmd, nil)
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil }
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "auth login") {
|
||||||
|
t.Fatalf("plain unauthenticated status = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||||
|
return nil, keychain.NewUnavailableError("read", errors.New("status"))
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "未登录") {
|
||||||
|
t.Fatalf("status error = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if out, err := runStatus("json"); err != nil || !strings.Contains(out, `"authenticated": false`) {
|
||||||
|
t.Fatalf("json status error = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour), CorpID: "ding", CorpName: "Corp"}
|
||||||
|
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
|
||||||
|
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "已登录") || !strings.Contains(out, "缺失或已过期") {
|
||||||
|
t.Fatalf("valid access status = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
valid.RefreshToken = "r"
|
||||||
|
valid.RefreshExpAt = now.Add(time.Hour)
|
||||||
|
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "Refresh Token:") {
|
||||||
|
t.Fatalf("valid refresh status = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"}
|
||||||
|
updated := &authpkg.TokenData{AccessToken: "new", ExpiresAt: now.Add(time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"}
|
||||||
|
calls := 0
|
||||||
|
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return expired, nil
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "new", nil }
|
||||||
|
if out, err := runStatus("table"); err != nil || !strings.Contains(out, "自动刷新") {
|
||||||
|
t.Fatalf("refreshed status = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
calls = 0
|
||||||
|
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return expired, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("second status")
|
||||||
|
}
|
||||||
|
if _, err := runStatus("table"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil }
|
||||||
|
authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") }
|
||||||
|
deleted := false
|
||||||
|
marked := false
|
||||||
|
authDeleteTokenData = func(string) error { deleted = true; return errors.New("ignored") }
|
||||||
|
authMarkProfileStatus = func(string, string, string) error { marked = true; return errors.New("ignored") }
|
||||||
|
edition.Override(&edition.Hooks{AutoPurgeToken: true})
|
||||||
|
if _, err := runStatus("table"); err != nil || !deleted {
|
||||||
|
t.Fatalf("auto-purge = %v, deleted=%v", err, deleted)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if _, err := runStatus("table"); err != nil || !marked {
|
||||||
|
t.Fatalf("mark-expired = %v, marked=%v", err, marked)
|
||||||
|
}
|
||||||
|
|
||||||
|
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") }
|
||||||
|
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
|
||||||
|
t.Fatal("missing profile should fail")
|
||||||
|
}
|
||||||
|
authResolveProfile = func(string, string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "ding"}, nil }
|
||||||
|
authRevokeToken = func(context.Context) error { return errors.New("ignored") }
|
||||||
|
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
|
||||||
|
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil {
|
||||||
|
t.Fatal("profile delete should fail")
|
||||||
|
}
|
||||||
|
authDeleteProfileToken = func(string, string) error { return nil }
|
||||||
|
if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authEnsureProfilesMigration = func(string) error { return errors.New("migrate") }
|
||||||
|
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||||
|
t.Fatal("migration should fail")
|
||||||
|
}
|
||||||
|
authEnsureProfilesMigration = func(string) error { return nil }
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, errors.New("load") }
|
||||||
|
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||||
|
t.Fatal("load profiles should fail")
|
||||||
|
}
|
||||||
|
revokes := 0
|
||||||
|
authRevokeToken = func(context.Context) error { revokes++; return nil }
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||||
|
authDeleteAllTokenData = func(string) error { return nil }
|
||||||
|
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 1 {
|
||||||
|
t.Fatalf("empty profiles = %v, revokes=%d", err, revokes)
|
||||||
|
}
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||||
|
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "a"}, {CorpID: "b"}}}, nil
|
||||||
|
}
|
||||||
|
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 3 {
|
||||||
|
t.Fatalf("profile revokes = %v, revokes=%d", err, revokes)
|
||||||
|
}
|
||||||
|
authDeleteAllTokenData = func(string) error { return errors.New("delete all") }
|
||||||
|
if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil {
|
||||||
|
t.Fatal("delete-all should fail")
|
||||||
|
}
|
||||||
|
logoutFailure := newAuthLogoutCommand()
|
||||||
|
_, _, _ = authCoverageRoot(logoutFailure, "table", false)
|
||||||
|
if err := logoutFailure.RunE(logoutFailure, nil); err == nil {
|
||||||
|
t.Fatal("logout-all command failure should propagate")
|
||||||
|
}
|
||||||
|
logoutFailure = newAuthLogoutCommand()
|
||||||
|
_, _, _ = authCoverageRoot(logoutFailure, "table", false)
|
||||||
|
_ = logoutFailure.Flags().Set("profile", "ding")
|
||||||
|
authDeleteProfileToken = func(string, string) error { return errors.New("delete") }
|
||||||
|
if err := logoutFailure.RunE(logoutFailure, nil); err == nil {
|
||||||
|
t.Fatal("logout-one command failure should propagate")
|
||||||
|
}
|
||||||
|
|
||||||
|
logout := newAuthLogoutCommand()
|
||||||
|
badLogout := &cobra.Command{}
|
||||||
|
badLogout.Flags().Bool("profile", false, "")
|
||||||
|
badLogout.SetContext(context.Background())
|
||||||
|
if err := logout.RunE(badLogout, nil); err == nil {
|
||||||
|
t.Fatal("invalid logout profile flag should fail")
|
||||||
|
}
|
||||||
|
authDeleteAllTokenData = func(string) error { return nil }
|
||||||
|
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return &authpkg.ProfilesConfig{}, nil }
|
||||||
|
_, out, _ := authCoverageRoot(logout, "table", false)
|
||||||
|
if err := logout.RunE(logout, nil); err != nil || !strings.Contains(out.String(), "重新登录") {
|
||||||
|
t.Fatalf("logout = %q, %v", out.String(), err)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||||
|
authDeleteProfileToken = func(string, string) error { return nil }
|
||||||
|
logout = newAuthLogoutCommand()
|
||||||
|
_, out, _ = authCoverageRoot(logout, "table", false)
|
||||||
|
if err := logout.Flags().Set("profile", "ding"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := logout.RunE(logout, nil); err != nil || strings.Contains(out.String(), "重新登录") {
|
||||||
|
t.Fatalf("embedded logout = %q, %v", out.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCoveragePortableExchangeAndReset(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldSupported := authPortableExportSupported
|
||||||
|
oldReady := authPortableSourceReady
|
||||||
|
oldTarget := authPortableTargetPopulated
|
||||||
|
oldExport := authExportPortableBundle
|
||||||
|
oldImport := authImportPortableBundle
|
||||||
|
oldAtomic := authAtomicWrite
|
||||||
|
oldRead := authReadFile
|
||||||
|
oldExchange := authOAuthExchange
|
||||||
|
oldDeleteAll := authDeleteAllTokenData
|
||||||
|
oldRemove := authRemove
|
||||||
|
oldDeleteConfig := authDeleteAppConfig
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
authPortableExportSupported = oldSupported
|
||||||
|
authPortableSourceReady = oldReady
|
||||||
|
authPortableTargetPopulated = oldTarget
|
||||||
|
authExportPortableBundle = oldExport
|
||||||
|
authImportPortableBundle = oldImport
|
||||||
|
authAtomicWrite = oldAtomic
|
||||||
|
authReadFile = oldRead
|
||||||
|
authOAuthExchange = oldExchange
|
||||||
|
authDeleteAllTokenData = oldDeleteAll
|
||||||
|
authRemove = oldRemove
|
||||||
|
authDeleteAppConfig = oldDeleteConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
export := newAuthExportCommand()
|
||||||
|
badString := &cobra.Command{}
|
||||||
|
badString.Flags().Bool("output", false, "")
|
||||||
|
if err := export.RunE(badString, nil); err == nil {
|
||||||
|
t.Fatal("invalid output flag should fail")
|
||||||
|
}
|
||||||
|
badBool := &cobra.Command{}
|
||||||
|
badBool.Flags().String("output", "x", "")
|
||||||
|
badBool.Flags().String("base64", "", "")
|
||||||
|
if err := export.RunE(badBool, nil); err == nil {
|
||||||
|
t.Fatal("invalid base64 flag should fail")
|
||||||
|
}
|
||||||
|
_, _, _ = authCoverageRoot(export, "table", false)
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("missing export output should fail")
|
||||||
|
}
|
||||||
|
authPortableExportSupported = func() bool { return false }
|
||||||
|
_ = export.Flags().Set("output", "out")
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("unsupported export should fail")
|
||||||
|
}
|
||||||
|
authPortableExportSupported = func() bool { return true }
|
||||||
|
authPortableSourceReady = func() bool { return false }
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("unready export should fail")
|
||||||
|
}
|
||||||
|
authPortableSourceReady = func() bool { return true }
|
||||||
|
authExportPortableBundle = func(string, io.Writer) error { return errors.New("export") }
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("export failure should propagate")
|
||||||
|
}
|
||||||
|
authExportPortableBundle = func(_ string, w io.Writer) error { _, _ = io.WriteString(w, "bundle"); return nil }
|
||||||
|
authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("raw write should fail")
|
||||||
|
}
|
||||||
|
authAtomicWrite = func(string, []byte, os.FileMode) error { return nil }
|
||||||
|
if err := export.RunE(export, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
export = newAuthExportCommand()
|
||||||
|
_, out, _ := authCoverageRoot(export, "table", false)
|
||||||
|
_ = export.Flags().Set("base64", "true")
|
||||||
|
export.SetOut(&appFailWriter{err: errors.New("stdout")})
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("stdout failure should propagate")
|
||||||
|
}
|
||||||
|
export.SetOut(out)
|
||||||
|
_ = export.Flags().Set("output", "encoded")
|
||||||
|
authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||||
|
if err := export.RunE(export, nil); err == nil {
|
||||||
|
t.Fatal("base64 write should fail")
|
||||||
|
}
|
||||||
|
authAtomicWrite = func(string, []byte, os.FileMode) error { return nil }
|
||||||
|
if err := export.RunE(export, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
importCmd := newAuthImportCommand()
|
||||||
|
badInput := &cobra.Command{}
|
||||||
|
badInput.Flags().Bool("input", false, "")
|
||||||
|
if err := importCmd.RunE(badInput, nil); err == nil {
|
||||||
|
t.Fatal("invalid input flag should fail")
|
||||||
|
}
|
||||||
|
badImportBase64 := &cobra.Command{}
|
||||||
|
badImportBase64.Flags().String("input", "x", "")
|
||||||
|
badImportBase64.Flags().String("base64", "", "")
|
||||||
|
if err := importCmd.RunE(badImportBase64, nil); err == nil {
|
||||||
|
t.Fatal("invalid import base64 flag should fail")
|
||||||
|
}
|
||||||
|
badForce := &cobra.Command{}
|
||||||
|
badForce.Flags().String("input", "x", "")
|
||||||
|
badForce.Flags().Bool("base64", false, "")
|
||||||
|
badForce.Flags().String("force", "", "")
|
||||||
|
if err := importCmd.RunE(badForce, nil); err == nil {
|
||||||
|
t.Fatal("invalid force flag should fail")
|
||||||
|
}
|
||||||
|
_, out, _ = authCoverageRoot(importCmd, "table", false)
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||||
|
t.Fatal("missing input should fail")
|
||||||
|
}
|
||||||
|
_ = importCmd.Flags().Set("input", "bundle")
|
||||||
|
authPortableTargetPopulated = func(string) bool { return true }
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||||
|
t.Fatal("populated target should require force")
|
||||||
|
}
|
||||||
|
authPortableTargetPopulated = func(string) bool { return false }
|
||||||
|
authReadFile = func(string) ([]byte, error) { return nil, errors.New("read") }
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||||
|
t.Fatal("read failure should propagate")
|
||||||
|
}
|
||||||
|
authReadFile = func(string) ([]byte, error) { return []byte("%%%"), nil }
|
||||||
|
_ = importCmd.Flags().Set("base64", "true")
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||||
|
t.Fatal("invalid base64 should fail")
|
||||||
|
}
|
||||||
|
authReadFile = func(string) ([]byte, error) { return []byte("YnVuZGxl"), nil }
|
||||||
|
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
|
||||||
|
return authpkg.PortableImportReport{}, errors.New("import")
|
||||||
|
}
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||||
|
t.Fatal("import failure should propagate")
|
||||||
|
}
|
||||||
|
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
|
||||||
|
return authpkg.PortableImportReport{BundleOS: "other", OSMismatch: true}, nil
|
||||||
|
}
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) {
|
||||||
|
return authpkg.PortableImportReport{}, nil
|
||||||
|
}
|
||||||
|
if err := importCmd.RunE(importCmd, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exchange := newAuthExchangeCommand()
|
||||||
|
badCode := &cobra.Command{}
|
||||||
|
badCode.Flags().Bool("code", false, "")
|
||||||
|
if err := exchange.RunE(badCode, nil); err == nil {
|
||||||
|
t.Fatal("invalid code flag should fail")
|
||||||
|
}
|
||||||
|
badUID := &cobra.Command{}
|
||||||
|
badUID.Flags().String("code", "code", "")
|
||||||
|
badUID.Flags().Bool("uid", false, "")
|
||||||
|
if err := exchange.RunE(badUID, nil); err == nil {
|
||||||
|
t.Fatal("invalid uid flag should fail")
|
||||||
|
}
|
||||||
|
_, out, _ = authCoverageRoot(exchange, "table", false)
|
||||||
|
if err := exchange.RunE(exchange, nil); err == nil {
|
||||||
|
t.Fatal("missing code should fail")
|
||||||
|
}
|
||||||
|
_ = exchange.Flags().Set("code", "code")
|
||||||
|
authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) {
|
||||||
|
return nil, errors.New("exchange")
|
||||||
|
}
|
||||||
|
if err := exchange.RunE(exchange, nil); err == nil {
|
||||||
|
t.Fatal("exchange error should propagate")
|
||||||
|
}
|
||||||
|
authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{CorpID: "ding", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
}
|
||||||
|
_ = exchange.Flags().Set("uid", " user ")
|
||||||
|
if err := exchange.RunE(exchange, nil); err != nil || !strings.Contains(out.String(), "ding") {
|
||||||
|
t.Fatalf("exchange = %q, %v", out.String(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reset := newAuthResetCommand()
|
||||||
|
_, out, _ = authCoverageRoot(reset, "table", false)
|
||||||
|
authDeleteAllTokenData = func(string) error { return errors.New("reset") }
|
||||||
|
if err := reset.RunE(reset, nil); err == nil {
|
||||||
|
t.Fatal("reset delete should fail")
|
||||||
|
}
|
||||||
|
removed := 0
|
||||||
|
authDeleteAllTokenData = func(string) error { return nil }
|
||||||
|
authRemove = func(string) error { removed++; return errors.New("ignored") }
|
||||||
|
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
|
||||||
|
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||||
|
reset = newAuthResetCommand()
|
||||||
|
_, out, _ = authCoverageRoot(reset, "table", false)
|
||||||
|
if err := reset.RunE(reset, nil); err != nil || strings.Contains(out.String(), "重新登录") {
|
||||||
|
t.Fatalf("embedded reset = %q, %v", out.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,10 +65,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
|||||||
case "", "json":
|
case "", "json":
|
||||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||||
case "pretty":
|
case "pretty":
|
||||||
data, err := json.MarshalIndent(notice, "", " ")
|
data, _ := json.MarshalIndent(notice, "", " ")
|
||||||
if err != nil {
|
var err error
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||||
return err
|
return err
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ func init() {
|
|||||||
|
|
||||||
// Build-time variables injected via ldflags when available.
|
// Build-time variables injected via ldflags when available.
|
||||||
var (
|
var (
|
||||||
buildTime = "unknown"
|
buildTime = "unknown"
|
||||||
gitCommit = "unknown"
|
gitCommit = "unknown"
|
||||||
|
userHomeDir = os.UserHomeDir
|
||||||
|
executablePath = os.Executable
|
||||||
|
evaluateSymlink = filepath.EvalSymlinks
|
||||||
)
|
)
|
||||||
|
|
||||||
func defaultConfigDir() string {
|
func defaultConfigDir() string {
|
||||||
@@ -44,7 +47,7 @@ func defaultConfigDir() string {
|
|||||||
if fn := edition.Get().ConfigDir; fn != nil {
|
if fn := edition.Get().ConfigDir; fn != nil {
|
||||||
return fn()
|
return fn()
|
||||||
}
|
}
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := userHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return exeRelativeConfigDir()
|
return exeRelativeConfigDir()
|
||||||
}
|
}
|
||||||
@@ -52,11 +55,11 @@ func defaultConfigDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func exeRelativeConfigDir() string {
|
func exeRelativeConfigDir() string {
|
||||||
exePath, err := os.Executable()
|
exePath, err := executablePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ".dws"
|
return ".dws"
|
||||||
}
|
}
|
||||||
realPath, err := filepath.EvalSymlinks(exePath)
|
realPath, err := evaluateSymlink(exePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
realPath = exePath
|
realPath = exePath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func TestToolCallerAdapterCoverage(t *testing.T) {
|
|||||||
if adapter.Format() != "raw" || !adapter.DryRun() || adapter.Fields() != "a" || adapter.JQ() != ".a" {
|
if adapter.Format() != "raw" || !adapter.DryRun() || adapter.Fields() != "a" || adapter.JQ() != ".a" {
|
||||||
t.Fatal("adapter flags were not forwarded")
|
t.Fatal("adapter flags were not forwarded")
|
||||||
}
|
}
|
||||||
|
adapter.flags.DryRun = false
|
||||||
|
|
||||||
runner.err = errors.New("runner failure")
|
runner.err = errors.New("runner failure")
|
||||||
if _, err := adapter.CallTool(context.Background(), "doc", "get", map[string]any{"x": 1}); !errors.Is(err, runner.err) {
|
if _, err := adapter.CallTool(context.Background(), "doc", "get", map[string]any{"x": 1}); !errors.Is(err, runner.err) {
|
||||||
@@ -1514,7 +1515,7 @@ func TestPersonalEventPureCoverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
args := personalBusSpawnArgs(identity, "custom", "https://ticket")
|
args := personalBusSpawnArgs(identity, "custom", "https://ticket")
|
||||||
if len(args) != 8 || len(personalBusSpawnArgs(identity, "", "")) != 4 {
|
if len(args) != 10 || len(personalBusSpawnArgs(identity, "", "")) != 6 {
|
||||||
t.Fatalf("personal bus args = %#v", args)
|
t.Fatalf("personal bus args = %#v", args)
|
||||||
}
|
}
|
||||||
if personalEventControlBaseURL(" https://control/ ", "") != "https://control" || personalEventStreamTicketURL(" https://ticket/ ", "") != "https://ticket" {
|
if personalEventControlBaseURL(" https://control/ ", "") != "https://control" || personalEventStreamTicketURL(" https://ticket/ ", "") != "https://ticket" {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -15,11 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient"
|
||||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -65,132 +60,13 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDynamicServerAndHelperFetcherCoverage(t *testing.T) {
|
func TestPluginAuthCoverage(t *testing.T) {
|
||||||
p := &plugin.Plugin{Manifest: plugin.Manifest{Name: "demo-plugin", Description: "demo"}}
|
|
||||||
timeouts := pluginColdTimeouts{httpNoAuth: time.Second, httpAuth: time.Second, stdio: time.Second}
|
|
||||||
runner := &coverageRunner{}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
initOK bool
|
|
||||||
listOK bool
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{false, true, 0},
|
|
||||||
{true, false, 0},
|
|
||||||
{true, true, 1},
|
|
||||||
} {
|
|
||||||
srv := appRPCServer(t, tc.initOK, tc.listOK)
|
|
||||||
desc := mcptypes.ServerDescriptor{Key: "server", Endpoint: srv.URL, CLI: mcptypes.CLIOverlay{ID: "demo"}}
|
|
||||||
client := transport.NewClient(nil)
|
|
||||||
client.TrustedDomains = []string{"127.0.0.1"}
|
|
||||||
tools := discoverHTTPTools(p, desc, client, timeouts)
|
|
||||||
if len(tools) != tc.want {
|
|
||||||
t.Fatalf("HTTP tools = %d, want %d", len(tools), tc.want)
|
|
||||||
}
|
|
||||||
_ = registerHTTPServer(p, desc, client, runner, timeouts)
|
|
||||||
srv.Close()
|
|
||||||
}
|
|
||||||
if got := buildHTTPCommandsFromTools(mcptypes.ServerDescriptor{}, nil, runner); got != nil {
|
|
||||||
t.Fatalf("removed HTTP command builder = %#v", got)
|
|
||||||
}
|
|
||||||
base := transport.NewClient(nil)
|
|
||||||
if buildPluginAuthClient(base, mcptypes.ServerDescriptor{AuthHeaders: map[string]string{"X": "Y"}}) != base {
|
|
||||||
t.Fatal("client without bearer token was copied")
|
|
||||||
}
|
|
||||||
authClient := buildPluginAuthClient(base, mcptypes.ServerDescriptor{Endpoint: "https://api.example.test", AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
|
||||||
if authClient == base || authClient.AuthToken != "token" || len(authClient.TrustedDomains) != 2 || authClient.ExtraHeaders["X"] != "Y" {
|
|
||||||
t.Fatalf("plugin auth client = %#v", authClient)
|
|
||||||
}
|
|
||||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
|
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}})
|
||||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}})
|
||||||
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
|
registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"})
|
||||||
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
|
if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" {
|
||||||
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
|
t.Fatalf("registered plugin auth = %#v, %v", got, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := appRPCServer(t, true, true)
|
|
||||||
oldEdition := edition.Get()
|
|
||||||
edition.Override(&edition.Hooks{StaticServers: func() []edition.ServerInfo {
|
|
||||||
return []edition.ServerInfo{{ID: "devdoc", Endpoint: server.URL}}
|
|
||||||
}})
|
|
||||||
t.Cleanup(func() { edition.Override(oldEdition); server.Close() })
|
|
||||||
fetcher := newHelperToolFetcher()
|
|
||||||
for range 2 {
|
|
||||||
schemas, err := fetcher(context.Background(), "devdoc")
|
|
||||||
if err != nil || schemas["tool"].Name != "tool" {
|
|
||||||
t.Fatalf("helper schemas = %#v, %v", schemas, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := fetcher(context.Background(), "missing"); err == nil {
|
|
||||||
t.Fatal("missing helper source succeeded")
|
|
||||||
}
|
|
||||||
if _, err := helperSourceEndpoint("op-app"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := inputSchemaProperties(nil); got == nil || len(got) != 0 {
|
|
||||||
t.Fatalf("nil properties = %#v", got)
|
|
||||||
}
|
|
||||||
if got := inputSchemaProperties(map[string]any{"properties": "bad"}); got == nil || len(got) != 0 {
|
|
||||||
t.Fatalf("bad properties = %#v", got)
|
|
||||||
}
|
|
||||||
if got := inputSchemaRequired(nil); got != nil {
|
|
||||||
t.Fatalf("nil required = %#v", got)
|
|
||||||
}
|
|
||||||
if got := inputSchemaRequired(map[string]any{"required": "bad"}); got != nil {
|
|
||||||
t.Fatalf("bad required = %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStdioDiscoveryAndRegistryCoverage(t *testing.T) {
|
|
||||||
p := &plugin.Plugin{Manifest: plugin.Manifest{Name: "stdio-plugin", Description: "demo"}}
|
|
||||||
timeouts := pluginColdTimeouts{stdio: 2 * time.Second}
|
|
||||||
notStarted := transport.NewStdioClient("ignored", nil, nil)
|
|
||||||
if got := discoverStdioTools(p, plugin.StdioServerClient{Key: "bad", Client: notStarted}, timeouts); got != nil {
|
|
||||||
t.Fatalf("unstarted stdio tools = %#v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
script := filepath.Join(t.TempDir(), "server.sh")
|
|
||||||
body := `#!/bin/sh
|
|
||||||
while IFS= read -r line; do
|
|
||||||
case "$line" in
|
|
||||||
*initialize*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}' ;;
|
|
||||||
*tools/list*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"echo","inputSchema":{}}]}}' ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
client := transport.NewStdioClient(script, nil, nil)
|
|
||||||
if err := client.Start(context.Background()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer client.Stop()
|
|
||||||
sc := plugin.StdioServerClient{Key: "local", Client: client}
|
|
||||||
tools := discoverStdioTools(p, sc, timeouts)
|
|
||||||
if len(tools) != 1 {
|
|
||||||
t.Fatalf("stdio tools = %#v", tools)
|
|
||||||
}
|
|
||||||
if got := registerStdioServer(p, sc, &coverageRunner{}, timeouts); got != nil {
|
|
||||||
t.Fatalf("stdio commands = %#v", got)
|
|
||||||
}
|
|
||||||
if got := buildStdioCommands(p, sc, nil, &coverageRunner{}); got != nil {
|
|
||||||
t.Fatalf("empty stdio commands = %#v", got)
|
|
||||||
}
|
|
||||||
if got, ok := LookupStdioClient("local"); !ok || got != client {
|
|
||||||
t.Fatalf("stdio suffix lookup = %#v, %v", got, ok)
|
|
||||||
}
|
|
||||||
if !StopStdioClient("stdio-plugin/local") || StopStdioClient("missing") {
|
|
||||||
t.Fatal("stdio stop result mismatch")
|
|
||||||
}
|
|
||||||
RegisterStdioClient("one", transport.NewStdioClient("ignored", nil, nil))
|
|
||||||
RegisterStdioClient("two", transport.NewStdioClient("ignored", nil, nil))
|
|
||||||
StopStdioClientsByPlugin("missing")
|
|
||||||
StopStdioClientsByPlugin("")
|
|
||||||
StopAllStdioClients()
|
|
||||||
if newPipelineEngine() == nil {
|
|
||||||
t.Fatal("pipeline engine is nil")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRawAPIAndTokenCoverage(t *testing.T) {
|
func TestRawAPIAndTokenCoverage(t *testing.T) {
|
||||||
@@ -333,11 +209,6 @@ func TestRootUtilityAndTimingCoverage(t *testing.T) {
|
|||||||
if !argsChanged([]string{"a"}, []string{"b"}) || argsChanged([]string{"a"}, []string{"a"}) {
|
if !argsChanged([]string{"a"}, []string{"b"}) || argsChanged([]string{"a"}, []string{"a"}) {
|
||||||
t.Fatal("argsChanged mismatch")
|
t.Fatal("argsChanged mismatch")
|
||||||
}
|
}
|
||||||
for _, raw := range []string{"", "bad", "0", "15ms"} {
|
|
||||||
t.Setenv(cli.PluginColdTimeoutEnv, raw)
|
|
||||||
_ = resolvePluginColdTimeouts()
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := &cobra.Command{Use: "root"}
|
cmd := &cobra.Command{Use: "root"}
|
||||||
cmd.SetContext(context.Background())
|
cmd.SetContext(context.Background())
|
||||||
cmd.Flags().String("output", "", "")
|
cmd.Flags().String("output", "", "")
|
||||||
|
|||||||
@@ -309,9 +309,6 @@ func editionServerEndpoint(productID string) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hooks := edition.Get()
|
hooks := edition.Get()
|
||||||
if hooks == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
|
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
|
||||||
return endpoint, true
|
return endpoint, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var doctorKeychainDiagnose = keychain.Diagnose
|
var (
|
||||||
|
doctorKeychainDiagnose = keychain.Diagnose
|
||||||
|
doctorAuthStatus = (*authpkg.OAuthProvider).Status
|
||||||
|
doctorAuthAccessToken = (*authpkg.OAuthProvider).GetAccessToken
|
||||||
|
doctorHTTPDo = (*http.Client).Do
|
||||||
|
doctorFetchLatestRelease = func() (*upgrade.ReleaseInfo, error) { return upgrade.NewClient().FetchLatestRelease() }
|
||||||
|
doctorNeedsUpgrade = upgrade.NeedsUpgrade
|
||||||
|
)
|
||||||
|
|
||||||
// checkStatus represents the outcome of a single doctor check.
|
// checkStatus represents the outcome of a single doctor check.
|
||||||
type checkStatus string
|
type checkStatus string
|
||||||
@@ -136,7 +143,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
|||||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
|
|
||||||
data, err := provider.Status()
|
data, err := doctorAuthStatus(provider)
|
||||||
if err != nil || data == nil {
|
if err != nil || data == nil {
|
||||||
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
||||||
r := checkResult{
|
r := checkResult{
|
||||||
@@ -164,7 +171,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
|||||||
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
|
if data.IsAccessTokenValid() || data.IsRefreshTokenValid() {
|
||||||
if !data.IsAccessTokenValid() {
|
if !data.IsAccessTokenValid() {
|
||||||
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
_, refreshErr := doctorAuthAccessToken(provider, refreshCtx)
|
||||||
cancel()
|
cancel()
|
||||||
if refreshErr != nil {
|
if refreshErr != nil {
|
||||||
r := checkResult{
|
r := checkResult{
|
||||||
@@ -263,7 +270,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := doctorHTTPDo(httpClient, req)
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r := checkResult{
|
r := checkResult{
|
||||||
@@ -317,8 +324,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
|||||||
|
|
||||||
currentVer := version
|
currentVer := version
|
||||||
|
|
||||||
client := upgrade.NewClient()
|
latest, err := doctorFetchLatestRelease()
|
||||||
latest, err := client.FetchLatestRelease()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r := checkResult{
|
r := checkResult{
|
||||||
Name: "version",
|
Name: "version",
|
||||||
@@ -332,7 +338,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
if upgrade.NeedsUpgrade(currentVer, latest.Version) {
|
if doctorNeedsUpgrade(currentVer, latest.Version) {
|
||||||
r := checkResult{
|
r := checkResult{
|
||||||
Name: "version",
|
Name: "version",
|
||||||
Status: statusWarn,
|
Status: statusWarn,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||||
|
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDoctorRemainingCoverage(t *testing.T) {
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldDiagnose := doctorKeychainDiagnose
|
||||||
|
oldStatus := doctorAuthStatus
|
||||||
|
oldAccess := doctorAuthAccessToken
|
||||||
|
oldHTTP := doctorHTTPDo
|
||||||
|
oldLatest := doctorFetchLatestRelease
|
||||||
|
oldNeeds := doctorNeedsUpgrade
|
||||||
|
oldRead := timingReadFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
doctorKeychainDiagnose = oldDiagnose
|
||||||
|
doctorAuthStatus = oldStatus
|
||||||
|
doctorAuthAccessToken = oldAccess
|
||||||
|
doctorHTTPDo = oldHTTP
|
||||||
|
doctorFetchLatestRelease = oldLatest
|
||||||
|
doctorNeedsUpgrade = oldNeeds
|
||||||
|
timingReadFile = oldRead
|
||||||
|
})
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
doctorKeychainDiagnose = func() keychain.Diagnostic { return keychain.Diagnostic{OK: true, Message: "ok"} }
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil }
|
||||||
|
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" {
|
||||||
|
t.Fatalf("missing auth = %#v", got)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||||
|
if got := doctorCheckAuth(context.Background(), io.Discard, true); got.Status != statusFail || got.Hint != "" {
|
||||||
|
t.Fatalf("embedded missing auth = %#v", got)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
now := time.Now()
|
||||||
|
valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour)}
|
||||||
|
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
|
||||||
|
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass {
|
||||||
|
t.Fatalf("valid auth = %#v", got)
|
||||||
|
}
|
||||||
|
refresh := &authpkg.TokenData{RefreshToken: "r", RefreshExpAt: now.Add(time.Hour)}
|
||||||
|
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return refresh, nil }
|
||||||
|
doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") }
|
||||||
|
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusWarn {
|
||||||
|
t.Fatalf("refresh failure = %#v", got)
|
||||||
|
}
|
||||||
|
doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "a", nil }
|
||||||
|
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass {
|
||||||
|
t.Fatalf("refresh success = %#v", got)
|
||||||
|
}
|
||||||
|
expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour)}
|
||||||
|
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil }
|
||||||
|
if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" {
|
||||||
|
t.Fatalf("expired auth = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(":"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail {
|
||||||
|
t.Fatalf("invalid network URL = %#v", got)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://example.test"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, errors.New("network") }
|
||||||
|
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail {
|
||||||
|
t.Fatalf("network failure = %#v", got)
|
||||||
|
}
|
||||||
|
doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||||
|
}
|
||||||
|
if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusPass {
|
||||||
|
t.Fatalf("network success = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return nil, errors.New("latest") }
|
||||||
|
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusFail {
|
||||||
|
t.Fatalf("version failure = %#v", got)
|
||||||
|
}
|
||||||
|
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: "99.0.0"}, nil }
|
||||||
|
doctorNeedsUpgrade = func(string, string) bool { return true }
|
||||||
|
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusWarn {
|
||||||
|
t.Fatalf("version warning = %#v", got)
|
||||||
|
}
|
||||||
|
doctorNeedsUpgrade = func(string, string) bool { return false }
|
||||||
|
if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusPass {
|
||||||
|
t.Fatalf("version pass = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil }
|
||||||
|
doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: version}, nil }
|
||||||
|
timingReadFile = func(string) ([]byte, error) {
|
||||||
|
return []byte(`{"command":"test","timestamp":"2026-01-01T00:00:00Z","phases":[]}`), nil
|
||||||
|
}
|
||||||
|
doctor := newDoctorCommand()
|
||||||
|
doctor.SetContext(context.Background())
|
||||||
|
doctor.SetOut(io.Discard)
|
||||||
|
_ = doctor.Flags().Set("json", "true")
|
||||||
|
_ = doctor.Flags().Set("perf", "true")
|
||||||
|
_ = doctor.Flags().Set("timeout", "0")
|
||||||
|
if err := doctor.RunE(doctor, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, errors.New("not logged in") }
|
||||||
|
doctor = newDoctorCommand()
|
||||||
|
doctor.SetContext(context.Background())
|
||||||
|
doctor.SetOut(io.Discard)
|
||||||
|
if err := doctor.RunE(doctor, nil); err == nil {
|
||||||
|
t.Fatal("doctor failures should return an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,31 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
eventRunPersonalConsume = runPersonalEventConsume
|
||||||
|
eventRunPersonalList = runPersonalEventList
|
||||||
|
eventRunPersonalStatus = runPersonalEventStatus
|
||||||
|
eventRunPersonalStop = runPersonalEventStop
|
||||||
|
eventNormalizeAs = normalizeEventAs
|
||||||
|
eventResolveCredentials = resolveEventCredentials
|
||||||
|
eventConsumeRun = consume.Run
|
||||||
|
eventRunForeground = runForegroundBus
|
||||||
|
eventNewEventSource = newEventSource
|
||||||
|
eventNewDingtalkSource = source.New
|
||||||
|
eventResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||||
|
eventBusRun = bus.Run
|
||||||
|
eventReadyFDFromEnv = busctl.ReadyFDFromEnv
|
||||||
|
eventResolvePersonal = resolvePersonalEventIdentity
|
||||||
|
eventNewPersonalSource = newPersonalStreamSource
|
||||||
|
eventMkdirAll = os.MkdirAll
|
||||||
|
eventOpenFile = os.OpenFile
|
||||||
|
eventEnumerateBuses = busctl.EnumerateBuses
|
||||||
|
eventFindBus = busctl.FindBusByClientID
|
||||||
|
eventQueryEntry = busctl.QueryEntry
|
||||||
|
eventStopBus = busctl.Stop
|
||||||
|
eventResolveAppCredentials = authpkg.ResolveAppCredentialsStrict
|
||||||
|
)
|
||||||
|
|
||||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||||
// Wired into root.go's utilityCommands list.
|
// Wired into root.go's utilityCommands list.
|
||||||
func newEventCommand() *cobra.Command {
|
func newEventCommand() *cobra.Command {
|
||||||
@@ -112,7 +137,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
|||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(c *cobra.Command, args []string) error {
|
RunE: func(c *cobra.Command, args []string) error {
|
||||||
as, err := normalizeEventAs(asIdentity)
|
as, err := eventNormalizeAs(asIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -135,7 +160,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
|||||||
personalOpts.StreamTicketMode = streamOpts.Mode
|
personalOpts.StreamTicketMode = streamOpts.Mode
|
||||||
personalOpts.StreamTicketURL = streamOpts.TicketURL
|
personalOpts.StreamTicketURL = streamOpts.TicketURL
|
||||||
personalOpts.StreamSourceID = streamOpts.SourceID
|
personalOpts.StreamSourceID = streamOpts.SourceID
|
||||||
return runPersonalEventConsume(c, personalOpts)
|
return eventRunPersonalConsume(c, personalOpts)
|
||||||
}
|
}
|
||||||
if personalOpts.DebugRawEvents {
|
if personalOpts.DebugRawEvents {
|
||||||
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
|
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
|
||||||
@@ -164,7 +189,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
|||||||
// Portal ticket normal mode uses portal-managed app credentials, so
|
// Portal ticket normal mode uses portal-managed app credentials, so
|
||||||
// local ClientSecret is intentionally not required there.
|
// local ClientSecret is intentionally not required there.
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
clientID, clientSecret, err := resolveEventCredentials(configDir, streamOpts)
|
clientID, clientSecret, err := eventResolveCredentials(configDir, streamOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event consume: %w", err)
|
return fmt.Errorf("event consume: %w", err)
|
||||||
}
|
}
|
||||||
@@ -239,9 +264,9 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
|||||||
// Step 6: foreground mode runs the bus in-process. Otherwise
|
// Step 6: foreground mode runs the bus in-process. Otherwise
|
||||||
// consume.Run discovers / forks the bus and dials it.
|
// consume.Run discovers / forks the bus and dials it.
|
||||||
if foreground {
|
if foreground {
|
||||||
return runForegroundBus(ctx, cfg, configDir, clientSecret, streamOpts)
|
return eventRunForeground(ctx, cfg, configDir, clientSecret, streamOpts)
|
||||||
}
|
}
|
||||||
return consume.Run(ctx, cfg)
|
return eventConsumeRun(ctx, cfg)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +349,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
|||||||
// can run `dws event consume` from another shell to consume the events.
|
// can run `dws event consume` from another shell to consume the events.
|
||||||
// v2 may add a "foreground + in-process consumer" combined mode.
|
// v2 may add a "foreground + in-process consumer" combined mode.
|
||||||
func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error {
|
func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error {
|
||||||
src, err := newEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
|
src, err := eventNewEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -339,7 +364,7 @@ func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, client
|
|||||||
Logger: slog.Default(),
|
Logger: slog.Default(),
|
||||||
}
|
}
|
||||||
bus.ApplyEnvTuning(&busCfg)
|
bus.ApplyEnvTuning(&busCfg)
|
||||||
return bus.Run(ctx, busCfg)
|
return eventBusRun(ctx, busCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
type eventStreamTicketOptions struct {
|
type eventStreamTicketOptions struct {
|
||||||
@@ -389,13 +414,13 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
|
|||||||
|
|
||||||
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||||
if !streamOpts.enabled() {
|
if !streamOpts.enabled() {
|
||||||
return source.New(source.Config{
|
return eventNewDingtalkSource(source.Config{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
token, err := eventResolveAccessToken(ctx, configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
|
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -410,7 +435,7 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
|
|||||||
portalClientSecret = ""
|
portalClientSecret = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.New(source.Config{
|
return eventNewDingtalkSource(source.Config{
|
||||||
ClientID: portalClientID,
|
ClientID: portalClientID,
|
||||||
ClientSecret: portalClientSecret,
|
ClientSecret: portalClientSecret,
|
||||||
PortalTicket: &source.PortalTicketConfig{
|
PortalTicket: &source.PortalTicketConfig{
|
||||||
@@ -474,7 +499,7 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
|
|
||||||
// Acquire ReadyPipe early so pre-bus.Run failures can signal
|
// Acquire ReadyPipe early so pre-bus.Run failures can signal
|
||||||
// 'E' to the parent process instead of silently dying.
|
// 'E' to the parent process instead of silently dying.
|
||||||
readyPipe := busctl.ReadyFDFromEnv()
|
readyPipe := eventReadyFDFromEnv()
|
||||||
failEarly := func(err error) error {
|
failEarly := func(err error) error {
|
||||||
if readyPipe != nil {
|
if readyPipe != nil {
|
||||||
// 'E' signals failure; the trailing text lets the parent
|
// 'E' signals failure; the trailing text lets the parent
|
||||||
@@ -495,7 +520,7 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
sourceKind = dwsevent.SourceKindAppStream
|
sourceKind = dwsevent.SourceKindAppStream
|
||||||
}
|
}
|
||||||
if sourceKind == dwsevent.SourceKindPersonalStream {
|
if sourceKind == dwsevent.SourceKindPersonalStream {
|
||||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, streamOpts.SourceID)
|
identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||||
}
|
}
|
||||||
@@ -506,7 +531,7 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
editionName := editionNameOrDefault()
|
editionName := editionNameOrDefault()
|
||||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||||
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{
|
||||||
ConfigDir: configDir,
|
ConfigDir: configDir,
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
TicketMode: streamOpts.Mode,
|
TicketMode: streamOpts.Mode,
|
||||||
@@ -516,8 +541,8 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return failEarly(err)
|
return failEarly(err)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
|
if err := eventMkdirAll(workDir, config.DirPerm); err == nil {
|
||||||
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
|
if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"),
|
||||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
|
||||||
defer lf.Close()
|
defer lf.Close()
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
@@ -537,10 +562,10 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
Logger: slog.Default(),
|
Logger: slog.Default(),
|
||||||
}
|
}
|
||||||
bus.ApplyEnvTuning(&busCfg)
|
bus.ApplyEnvTuning(&busCfg)
|
||||||
return bus.Run(ctx, busCfg)
|
return eventBusRun(ctx, busCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedID, secret, err := resolveEventCredentials(configDir, streamOpts)
|
resolvedID, secret, err := eventResolveCredentials(configDir, streamOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failEarly(fmt.Errorf("event _bus: %w", err))
|
return failEarly(fmt.Errorf("event _bus: %w", err))
|
||||||
}
|
}
|
||||||
@@ -553,7 +578,7 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||||
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||||
|
|
||||||
src, err := newEventSource(ctx, configDir, clientID, secret, streamOpts)
|
src, err := eventNewEventSource(ctx, configDir, clientID, secret, streamOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failEarly(err)
|
return failEarly(err)
|
||||||
}
|
}
|
||||||
@@ -562,8 +587,8 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
// own log lines never pollute stdout/stderr (which busctl/Spawn
|
// own log lines never pollute stdout/stderr (which busctl/Spawn
|
||||||
// detached). Best-effort: if mkdir / open fails we fall back
|
// detached). Best-effort: if mkdir / open fails we fall back
|
||||||
// to slog.Default (stderr) so we at least see startup errors.
|
// to slog.Default (stderr) so we at least see startup errors.
|
||||||
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
|
if err := eventMkdirAll(workDir, config.DirPerm); err == nil {
|
||||||
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
|
if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"),
|
||||||
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
|
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
|
||||||
defer lf.Close()
|
defer lf.Close()
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
@@ -585,7 +610,7 @@ func newEventBusCommand() *cobra.Command {
|
|||||||
// env-var tuning (only fills in fields left at zero; explicit
|
// env-var tuning (only fills in fields left at zero; explicit
|
||||||
// flags above keep precedence).
|
// flags above keep precedence).
|
||||||
bus.ApplyEnvTuning(&busCfg)
|
bus.ApplyEnvTuning(&busCfg)
|
||||||
return bus.Run(ctx, busCfg)
|
return eventBusRun(ctx, busCfg)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&clientIDOverride, "client-id", "",
|
cmd.Flags().StringVar(&clientIDOverride, "client-id", "",
|
||||||
@@ -642,7 +667,7 @@ func newEventListCommand() *cobra.Command {
|
|||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(c *cobra.Command, _ []string) error {
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
as, err := normalizeEventAs(asIdentity)
|
as, err := eventNormalizeAs(asIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -650,7 +675,7 @@ func newEventListCommand() *cobra.Command {
|
|||||||
if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil {
|
if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil {
|
||||||
return fmt.Errorf("event list: %w", err)
|
return fmt.Errorf("event list: %w", err)
|
||||||
}
|
}
|
||||||
return runPersonalEventList(c, personalListOptions{
|
return eventRunPersonalList(c, personalListOptions{
|
||||||
Category: category,
|
Category: category,
|
||||||
EnabledOnly: enabledOnly,
|
EnabledOnly: enabledOnly,
|
||||||
IncludePending: includePending,
|
IncludePending: includePending,
|
||||||
@@ -704,7 +729,7 @@ func newEventStatusCommand() *cobra.Command {
|
|||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(c *cobra.Command, _ []string) error {
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
as, err := normalizeEventAs(asIdentity)
|
as, err := eventNormalizeAs(asIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -713,7 +738,7 @@ func newEventStatusCommand() *cobra.Command {
|
|||||||
return fmt.Errorf("event status: %w", err)
|
return fmt.Errorf("event status: %w", err)
|
||||||
}
|
}
|
||||||
personalOpts.Format = formatRaw
|
personalOpts.Format = formatRaw
|
||||||
return runPersonalEventStatus(c, personalOpts)
|
return eventRunPersonalStatus(c, personalOpts)
|
||||||
}
|
}
|
||||||
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
|
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||||
return fmt.Errorf("event status: %w", err)
|
return fmt.Errorf("event status: %w", err)
|
||||||
@@ -764,14 +789,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
|
|||||||
|
|
||||||
// --all-editions trumps --all (scan whole tree)
|
// --all-editions trumps --all (scan whole tree)
|
||||||
if allEditions {
|
if allEditions {
|
||||||
entries, err := busctl.EnumerateBuses(configDir, "")
|
entries, err := eventEnumerateBuses(configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return queryAll(entries), nil
|
return queryAll(entries), nil
|
||||||
}
|
}
|
||||||
if all {
|
if all {
|
||||||
entries, err := busctl.EnumerateBuses(configDir, editionName)
|
entries, err := eventEnumerateBuses(configDir, editionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -782,14 +807,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
|
|||||||
// otherwise resolve via strict resolver.
|
// otherwise resolve via strict resolver.
|
||||||
clientID := clientIDOver
|
clientID := clientIDOver
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
resolved, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
|
resolved, _, _, _, err := eventResolveAppCredentials(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err)
|
return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err)
|
||||||
}
|
}
|
||||||
clientID = resolved
|
clientID = resolved
|
||||||
}
|
}
|
||||||
hash := dwsevent.ClientIDHash(clientID)
|
hash := dwsevent.ClientIDHash(clientID)
|
||||||
entry := busctl.FindBusByClientID(configDir, editionName, hash)
|
entry := eventFindBus(configDir, editionName, hash)
|
||||||
if entry == nil {
|
if entry == nil {
|
||||||
// No directory at all — render an empty "not running" so the user
|
// No directory at all — render an empty "not running" so the user
|
||||||
// sees a useful answer instead of an error.
|
// sees a useful answer instead of an error.
|
||||||
@@ -813,13 +838,13 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool
|
|||||||
if entry.Meta == nil {
|
if entry.Meta == nil {
|
||||||
entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName}
|
entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName}
|
||||||
}
|
}
|
||||||
return []busctl.EntryStatus{busctl.QueryEntry(*entry)}, nil
|
return []busctl.EntryStatus{eventQueryEntry(*entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus {
|
func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus {
|
||||||
out := make([]busctl.EntryStatus, 0, len(entries))
|
out := make([]busctl.EntryStatus, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
out = append(out, busctl.QueryEntry(e))
|
out = append(out, eventQueryEntry(e))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -989,7 +1014,7 @@ func newEventStopCommand() *cobra.Command {
|
|||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(c *cobra.Command, args []string) error {
|
RunE: func(c *cobra.Command, args []string) error {
|
||||||
as, err := normalizeEventAs(asIdentity)
|
as, err := eventNormalizeAs(asIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1033,7 @@ func newEventStopCommand() *cobra.Command {
|
|||||||
if !eventStopConfirmed(c) {
|
if !eventStopConfirmed(c) {
|
||||||
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
|
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
|
||||||
}
|
}
|
||||||
return runPersonalEventStop(c, opts)
|
return eventRunPersonalStop(c, opts)
|
||||||
}
|
}
|
||||||
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||||
return fmt.Errorf("event stop: %w", err)
|
return fmt.Errorf("event stop: %w", err)
|
||||||
@@ -1023,14 +1048,14 @@ func newEventStopCommand() *cobra.Command {
|
|||||||
return eventStopConfirmationRequired("event stop 会停止事件消费")
|
return eventStopConfirmationRequired("event stop 会停止事件消费")
|
||||||
}
|
}
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
clientID, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
|
clientID, _, _, _, err := eventResolveAppCredentials(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event stop: %w", err)
|
return fmt.Errorf("event stop: %w", err)
|
||||||
}
|
}
|
||||||
editionName := editionNameOrDefault()
|
editionName := editionNameOrDefault()
|
||||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||||
if errors.Is(err, busctl.ErrNotRunning) {
|
if errors.Is(err, busctl.ErrNotRunning) {
|
||||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||||
|
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventConsumeCommandAllBranchesCoverage(t *testing.T) {
|
||||||
|
oldPersonal := eventRunPersonalConsume
|
||||||
|
oldCreds, oldConsume, oldForeground := eventResolveCredentials, eventConsumeRun, eventRunForeground
|
||||||
|
oldNormalize := eventNormalizeAs
|
||||||
|
t.Cleanup(func() {
|
||||||
|
eventRunPersonalConsume = oldPersonal
|
||||||
|
eventResolveCredentials, eventConsumeRun, eventRunForeground = oldCreds, oldConsume, oldForeground
|
||||||
|
eventNormalizeAs = oldNormalize
|
||||||
|
})
|
||||||
|
eventNormalizeAs = func(value string) (string, error) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(value), "app") {
|
||||||
|
return "app", nil
|
||||||
|
}
|
||||||
|
return normalizeEventAs(value)
|
||||||
|
}
|
||||||
|
fail := errors.New("failure")
|
||||||
|
personalCalled := false
|
||||||
|
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error { personalCalled = true; return nil }
|
||||||
|
cmd := newEventConsumeCommand()
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
if err := cmd.RunE(cmd, []string{"event.key"}); err != nil || !personalCalled {
|
||||||
|
t.Fatalf("personal consume = %v, called=%v", err, personalCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
makeApp := func() *cobra.Command {
|
||||||
|
command := newEventConsumeCommand()
|
||||||
|
command.SetContext(context.Background())
|
||||||
|
command.SetOut(io.Discard)
|
||||||
|
command.SetErr(io.Discard)
|
||||||
|
_ = command.Flags().Set("as", "app")
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
invalid := newEventConsumeCommand()
|
||||||
|
_ = invalid.Flags().Set("as", "invalid")
|
||||||
|
if err := invalid.RunE(invalid, nil); err == nil {
|
||||||
|
t.Fatal("invalid event identity succeeded")
|
||||||
|
}
|
||||||
|
debug := makeApp()
|
||||||
|
_ = debug.Flags().Set("debug-raw-events", "true")
|
||||||
|
if err := debug.RunE(debug, nil); err == nil {
|
||||||
|
t.Fatal("app debug-raw-events succeeded")
|
||||||
|
}
|
||||||
|
userFlag := makeApp()
|
||||||
|
_ = userFlag.Flags().Set("subscribe-id", "sub")
|
||||||
|
if err := userFlag.RunE(userFlag, nil); err == nil {
|
||||||
|
t.Fatal("app personal flag succeeded")
|
||||||
|
}
|
||||||
|
if err := makeApp().RunE(makeApp(), []string{"event.key"}); err == nil {
|
||||||
|
t.Fatal("app event key succeeded")
|
||||||
|
}
|
||||||
|
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail }
|
||||||
|
if err := makeApp().RunE(makeApp(), nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("event credentials error = %v", err)
|
||||||
|
}
|
||||||
|
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil }
|
||||||
|
badRoute := makeApp()
|
||||||
|
_ = badRoute.Flags().Set("route", "bad")
|
||||||
|
if err := badRoute.RunE(badRoute, nil); err == nil {
|
||||||
|
t.Fatal("invalid event route succeeded")
|
||||||
|
}
|
||||||
|
invalidConfig := makeApp()
|
||||||
|
_ = invalidConfig.Flags().Set("format", "json")
|
||||||
|
if err := invalidConfig.RunE(invalidConfig, nil); err == nil {
|
||||||
|
t.Fatal("unbounded JSON event stream succeeded")
|
||||||
|
}
|
||||||
|
conflict := makeApp()
|
||||||
|
conflict.Flags().String("output", "", "")
|
||||||
|
_ = conflict.Flags().Set("output-dir", t.TempDir())
|
||||||
|
_ = conflict.Flags().Set("output", filepath.Join(t.TempDir(), "out"))
|
||||||
|
if err := conflict.RunE(conflict, nil); err == nil {
|
||||||
|
t.Fatal("event output conflict succeeded")
|
||||||
|
}
|
||||||
|
eventConsumeRun = func(context.Context, consume.Config) error { return fail }
|
||||||
|
t.Setenv(authpkg.EnvClientID, "half-set")
|
||||||
|
t.Setenv(authpkg.EnvClientSecret, "")
|
||||||
|
valid := makeApp()
|
||||||
|
_ = valid.Flags().Set("format", "table")
|
||||||
|
_ = valid.Flags().Set("max-events", "1")
|
||||||
|
if err := valid.RunE(valid, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("event consume run error = %v", err)
|
||||||
|
}
|
||||||
|
eventRunForeground = func(context.Context, consume.Config, string, string, eventStreamTicketOptions) error { return fail }
|
||||||
|
foreground := makeApp()
|
||||||
|
_ = foreground.Flags().Set("foreground", "true")
|
||||||
|
if err := foreground.RunE(foreground, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("foreground event error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventSourcesAndForegroundCoverage(t *testing.T) {
|
||||||
|
oldNew, oldToken, oldEventSource, oldBus := eventNewDingtalkSource, eventResolveAccessToken, eventNewEventSource, eventBusRun
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
eventNewDingtalkSource, eventResolveAccessToken = oldNew, oldToken
|
||||||
|
eventNewEventSource, eventBusRun = oldEventSource, oldBus
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) { return nil, fail }
|
||||||
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("SDK event source error = %v", err)
|
||||||
|
}
|
||||||
|
eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) {
|
||||||
|
return &source.DingtalkSource{}, nil
|
||||||
|
}
|
||||||
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
|
||||||
|
stream := eventStreamTicketOptions{Mode: "custom"}
|
||||||
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("stream token error = %v", err)
|
||||||
|
}
|
||||||
|
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
|
||||||
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
|
||||||
|
t.Fatal("empty stream token succeeded")
|
||||||
|
}
|
||||||
|
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
||||||
|
for _, mode := range []string{"custom", "normal"} {
|
||||||
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{Mode: mode}); err != nil {
|
||||||
|
t.Fatalf("stream source %s = %v", mode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edition.Override(&edition.Hooks{Name: "test"})
|
||||||
|
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
if err := runForegroundBus(context.Background(), consume.Config{}, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("foreground source error = %v", err)
|
||||||
|
}
|
||||||
|
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||||
|
return &source.DingtalkSource{}, nil
|
||||||
|
}
|
||||||
|
eventBusRun = func(context.Context, bus.Config) error { return fail }
|
||||||
|
cfg := consume.Config{WorkDir: t.TempDir(), IPCEndpoint: filepath.Join(t.TempDir(), "bus.sock"), ClientID: "client"}
|
||||||
|
if err := runForegroundBus(context.Background(), cfg, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("foreground bus error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
if err := os.MkdirAll(filepath.Join(home, ".dws"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(home, ".dws", "mcp_url"), []byte("https://pre-mcp.example.test"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_STREAM_SOURCE_ID", "")
|
||||||
|
if got := defaultEventStreamSourceID(); got != "pre_open_source" {
|
||||||
|
t.Fatalf("pre stream source ID = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventBusCommandAllBranchesCoverage(t *testing.T) {
|
||||||
|
oldReady, oldPersonal, oldPersonalSource := eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource
|
||||||
|
oldCreds, oldSource, oldRun := eventResolveCredentials, eventNewEventSource, eventBusRun
|
||||||
|
oldMkdir, oldOpen := eventMkdirAll, eventOpenFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource = oldReady, oldPersonal, oldPersonalSource
|
||||||
|
eventResolveCredentials, eventNewEventSource, eventBusRun = oldCreds, oldSource, oldRun
|
||||||
|
eventMkdirAll, eventOpenFile = oldMkdir, oldOpen
|
||||||
|
})
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
fail := errors.New("failure")
|
||||||
|
makeBus := func(kind string) *cobra.Command {
|
||||||
|
cmd := newEventBusCommand()
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
_ = cmd.Flags().Set("source-kind", kind)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
read, write, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eventReadyFDFromEnv = func() *os.File { return write }
|
||||||
|
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, fail }
|
||||||
|
if err := makeBus(string(dwsevent.SourceKindPersonalStream)).RunE(makeBus(string(dwsevent.SourceKindPersonalStream)), nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("personal identity error = %v", err)
|
||||||
|
}
|
||||||
|
marker := make([]byte, 1)
|
||||||
|
_, _ = read.Read(marker)
|
||||||
|
_ = read.Close()
|
||||||
|
if marker[0] != 'E' {
|
||||||
|
t.Fatalf("ready failure marker = %q", marker)
|
||||||
|
}
|
||||||
|
eventReadyFDFromEnv = func() *os.File { return nil }
|
||||||
|
eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) {
|
||||||
|
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||||
|
}
|
||||||
|
eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, fail }
|
||||||
|
personalCmd := makeBus(string(dwsevent.SourceKindPersonalStream))
|
||||||
|
_ = personalCmd.Flags().Set("client-id", "override")
|
||||||
|
if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("personal stream source error = %v", err)
|
||||||
|
}
|
||||||
|
eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||||
|
return &source.PersonalSource{}, nil
|
||||||
|
}
|
||||||
|
eventMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
eventOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "bus-log") }
|
||||||
|
eventBusRun = func(context.Context, bus.Config) error { return fail }
|
||||||
|
if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("personal bus run error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail }
|
||||||
|
if err := makeBus(string(dwsevent.SourceKindAppStream)).RunE(makeBus(string(dwsevent.SourceKindAppStream)), nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app bus credentials error = %v", err)
|
||||||
|
}
|
||||||
|
eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil }
|
||||||
|
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
appCmd := makeBus("")
|
||||||
|
_ = appCmd.Flags().Set("client-id", "override")
|
||||||
|
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app event source error = %v", err)
|
||||||
|
}
|
||||||
|
eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||||
|
return &source.DingtalkSource{}, nil
|
||||||
|
}
|
||||||
|
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app bus run error = %v", err)
|
||||||
|
}
|
||||||
|
eventMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app bus run after log mkdir failure = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventListStatusCollectAndStopCoverage(t *testing.T) {
|
||||||
|
oldList, oldStatus, oldStopPersonal := eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop
|
||||||
|
oldEnum, oldFind, oldQuery, oldStop := eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus
|
||||||
|
oldCreds := eventResolveAppCredentials
|
||||||
|
oldNormalize := eventNormalizeAs
|
||||||
|
t.Cleanup(func() {
|
||||||
|
eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop = oldList, oldStatus, oldStopPersonal
|
||||||
|
eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus = oldEnum, oldFind, oldQuery, oldStop
|
||||||
|
eventResolveAppCredentials = oldCreds
|
||||||
|
eventNormalizeAs = oldNormalize
|
||||||
|
})
|
||||||
|
eventNormalizeAs = func(value string) (string, error) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(value), "app") {
|
||||||
|
return "app", nil
|
||||||
|
}
|
||||||
|
return normalizeEventAs(value)
|
||||||
|
}
|
||||||
|
fail := errors.New("failure")
|
||||||
|
eventRunPersonalList = func(*cobra.Command, personalListOptions) error { return fail }
|
||||||
|
list := newEventListCommand()
|
||||||
|
list.SetOut(io.Discard)
|
||||||
|
if err := list.RunE(list, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("personal list error = %v", err)
|
||||||
|
}
|
||||||
|
eventRunPersonalStatus = func(*cobra.Command, personalStatusOptions) error { return fail }
|
||||||
|
status := newEventStatusCommand()
|
||||||
|
status.SetOut(io.Discard)
|
||||||
|
if err := status.RunE(status, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("personal status error = %v", err)
|
||||||
|
}
|
||||||
|
eventRunPersonalStop = func(*cobra.Command, personalStopOptions) error { return fail }
|
||||||
|
stop := newEventStopCommand()
|
||||||
|
stop.SetOut(io.Discard)
|
||||||
|
stop.Flags().Bool("yes", false, "")
|
||||||
|
_ = stop.Flags().Set("yes", "true")
|
||||||
|
if err := stop.RunE(stop, []string{"sub"}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("personal stop error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) { return nil, fail }
|
||||||
|
if _, err := collectEntries(&cobra.Command{}, "client", false, true); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("all-editions collect error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := collectEntries(&cobra.Command{}, "client", true, false); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("all collect error = %v", err)
|
||||||
|
}
|
||||||
|
eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) {
|
||||||
|
return []busctl.BusEntry{{ClientIDHash: "hash"}}, nil
|
||||||
|
}
|
||||||
|
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
|
||||||
|
if got, err := collectEntries(&cobra.Command{}, "client", false, true); err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("all-editions entries = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := collectEntries(&cobra.Command{}, "client", true, false); err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("all entries = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
|
||||||
|
}
|
||||||
|
if _, err := collectEntries(&cobra.Command{}, "", false, false); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("single credentials error = %v", err)
|
||||||
|
}
|
||||||
|
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
|
||||||
|
}
|
||||||
|
eventFindBus = func(string, string, string) *busctl.BusEntry { return nil }
|
||||||
|
if got, err := collectEntries(&cobra.Command{}, "", false, false); err != nil || len(got) != 1 || got[0].Entry.State != busctl.BusStateNotRunning {
|
||||||
|
t.Fatalf("missing bus entry = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
eventFindBus = func(string, string, string) *busctl.BusEntry { return &busctl.BusEntry{} }
|
||||||
|
if got, err := collectEntries(&cobra.Command{}, "client", false, false); err != nil || len(got) != 1 || got[0].Entry.Meta == nil {
|
||||||
|
t.Fatalf("found bus entry = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appList := newEventListCommand()
|
||||||
|
appList.SetOut(io.Discard)
|
||||||
|
_ = appList.Flags().Set("as", "app")
|
||||||
|
_ = appList.Flags().Set("client-id", "client")
|
||||||
|
if err := appList.RunE(appList, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rejectedList := newEventListCommand()
|
||||||
|
_ = rejectedList.Flags().Set("as", "app")
|
||||||
|
_ = rejectedList.Flags().Set("category", "chat")
|
||||||
|
if err := rejectedList.RunE(rejectedList, nil); err == nil {
|
||||||
|
t.Fatal("app list personal flag succeeded")
|
||||||
|
}
|
||||||
|
eventFindBus = func(string, string, string) *busctl.BusEntry {
|
||||||
|
return &busctl.BusEntry{State: busctl.BusStateOrphan, Meta: &bus.Meta{ClientID: "client"}}
|
||||||
|
}
|
||||||
|
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
|
||||||
|
appStatus := newEventStatusCommand()
|
||||||
|
appStatus.SetOut(io.Discard)
|
||||||
|
_ = appStatus.Flags().Set("as", "app")
|
||||||
|
_ = appStatus.Flags().Set("client-id", "client")
|
||||||
|
_ = appStatus.Flags().Set("fail-on-orphan", "true")
|
||||||
|
if err := appStatus.RunE(appStatus, nil); err == nil {
|
||||||
|
t.Fatal("orphan event status succeeded")
|
||||||
|
}
|
||||||
|
rejectedStatus := newEventStatusCommand()
|
||||||
|
_ = rejectedStatus.Flags().Set("as", "app")
|
||||||
|
_ = rejectedStatus.Flags().Set("event", "key")
|
||||||
|
if err := rejectedStatus.RunE(rejectedStatus, nil); err == nil {
|
||||||
|
t.Fatal("app status personal flag succeeded")
|
||||||
|
}
|
||||||
|
|
||||||
|
appStop := func() *cobra.Command {
|
||||||
|
cmd := newEventStopCommand()
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.Flags().Bool("yes", true, "")
|
||||||
|
_ = cmd.Flags().Set("as", "app")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
changedStop := appStop()
|
||||||
|
_ = changedStop.Flags().Set("all", "true")
|
||||||
|
if err := changedStop.RunE(changedStop, nil); err == nil {
|
||||||
|
t.Fatal("app stop personal flag succeeded")
|
||||||
|
}
|
||||||
|
if err := appStop().RunE(appStop(), []string{"sub"}); err == nil {
|
||||||
|
t.Fatal("app subscribe ID succeeded")
|
||||||
|
}
|
||||||
|
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
|
||||||
|
}
|
||||||
|
if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app stop credentials error = %v", err)
|
||||||
|
}
|
||||||
|
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
|
||||||
|
}
|
||||||
|
eventStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
|
||||||
|
if err := appStop().RunE(appStop(), nil); err != nil {
|
||||||
|
t.Fatalf("already stopped bus = %v", err)
|
||||||
|
}
|
||||||
|
eventStopBus = func(busctl.StopConfig) error { return fail }
|
||||||
|
if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("stop bus error = %v", err)
|
||||||
|
}
|
||||||
|
eventStopBus = func(busctl.StopConfig) error { return nil }
|
||||||
|
if err := appStop().RunE(appStop(), nil); err != nil {
|
||||||
|
t.Fatalf("stop bus success = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventCommandParentCoverage(t *testing.T) {
|
||||||
|
cmd := newEventCommand()
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
if err := cmd.RunE(cmd, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Use, "event") {
|
||||||
|
t.Fatal("event command use changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventCommandPureAndRenderBranchesCoverage(t *testing.T) {
|
||||||
|
t.Setenv(authpkg.EnvClientID, "client")
|
||||||
|
t.Setenv(authpkg.EnvClientSecret, "secret")
|
||||||
|
if got := (eventStreamTicketOptions{Mode: "custom", SourceID: " source ", TicketURL: " https://ticket "}).spawnArgs(); len(got) != 6 {
|
||||||
|
t.Fatalf("stream spawn args = %#v", got)
|
||||||
|
}
|
||||||
|
if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{}); err != nil || id != "client" || secret != "secret" {
|
||||||
|
t.Fatalf("app credentials = %q, %q, %v", id, secret, err)
|
||||||
|
}
|
||||||
|
if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{Mode: "normal", SourceID: "source"}); err != nil || id != "portal-ticket-normal:source" || secret != "" {
|
||||||
|
t.Fatalf("portal credentials = %q, %q, %v", id, secret, err)
|
||||||
|
}
|
||||||
|
if eventStreamTicketURL(" https://ticket ") != "https://ticket" || eventStreamSourceID(" source ") != "source" {
|
||||||
|
t.Fatal("explicit event stream routing changed")
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_STREAM_SOURCE_ID", "environment")
|
||||||
|
if defaultEventStreamSourceID() != "environment" {
|
||||||
|
t.Fatal("environment stream source ID ignored")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||||
|
if editionNameOrDefault() != "open" || sourceKindLabel("") != string(dwsevent.SourceKindAppStream) {
|
||||||
|
t.Fatal("default event labels changed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(eventWorkDir("config", "open", "", "hash"), string(dwsevent.SourceKindAppStream)) {
|
||||||
|
t.Fatal("default event workdir changed")
|
||||||
|
}
|
||||||
|
if _, err := normalizeEventAs("bot"); err == nil {
|
||||||
|
t.Fatal("bot events became public unexpectedly")
|
||||||
|
}
|
||||||
|
flags := &cobra.Command{}
|
||||||
|
flags.Flags().Bool("all", false, "")
|
||||||
|
if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = flags.Flags().Set("all", "true")
|
||||||
|
if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err == nil {
|
||||||
|
t.Fatal("personal unsupported flag accepted")
|
||||||
|
}
|
||||||
|
if firstArg(nil) != "" || firstArg([]string{"value"}) != "value" || len(eventTypesWithDefault([]string{"type"})) != 1 {
|
||||||
|
t.Fatal("event helper defaults changed")
|
||||||
|
}
|
||||||
|
_ = eventTypesWithDefault(nil)
|
||||||
|
|
||||||
|
live := &eventtransport.StatusResp{
|
||||||
|
Bus: eventtransport.StatusBus{UptimeSecs: 12},
|
||||||
|
SourceState: eventtransport.StatusSource{State: "connected", Source: "hook", ReconnectCount: 1},
|
||||||
|
Consumers: []eventtransport.StatusConsumer{
|
||||||
|
{PID: 1, Received: 2, Dropped: 3},
|
||||||
|
{PID: 2, EventTypes: []string{"chat"}, SubscribeID: "sub"},
|
||||||
|
},
|
||||||
|
PerEventTypeCounters: map[string]eventtransport.Counters{"chat": {Received: 2, Dropped: 1}},
|
||||||
|
}
|
||||||
|
entries := []busctl.EntryStatus{
|
||||||
|
{Entry: busctl.BusEntry{State: busctl.BusStateNotRunning, ClientIDHash: "not-running"}},
|
||||||
|
{Entry: busctl.BusEntry{State: busctl.BusStateOrphan, HolderPID: 3, Meta: &bus.Meta{ClientID: "orphan", SourceID: "source", StartedAt: time.Now()}}},
|
||||||
|
{Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 4, Meta: &bus.Meta{ClientID: "offline", StartedAt: time.Now().Add(-time.Minute)}}},
|
||||||
|
{Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 5, Meta: &bus.Meta{ClientID: "live"}}, Live: live},
|
||||||
|
}
|
||||||
|
if err := renderStatus(io.Discard, entries, "text"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := renderStatus(io.Discard, entries, "json"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := renderStatus(appFailWriter{err: errors.New("write")}, entries, "json"); err == nil {
|
||||||
|
t.Fatal("status JSON write failure succeeded")
|
||||||
|
}
|
||||||
|
listEntries := []listEntry{
|
||||||
|
{ClientIDHash: "hash", BusState: busctl.BusStateNotRunning},
|
||||||
|
{ClientID: "client", SourceKind: dwsevent.SourceKindPersonalStream, BusState: busctl.BusStateRunning, BusPID: 2, Consumers: live.Consumers},
|
||||||
|
}
|
||||||
|
if err := renderList(io.Discard, listEntries, "table"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := renderList(io.Discard, listEntries, "json"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := renderList(appFailWriter{err: errors.New("write")}, listEntries, "json"); err == nil {
|
||||||
|
t.Fatal("list JSON write failure succeeded")
|
||||||
|
}
|
||||||
|
if got := buildListEntry(busctl.EntryStatus{Entry: busctl.BusEntry{Meta: &bus.Meta{ClientID: "client"}}, Live: live}); len(got.Consumers) != 2 {
|
||||||
|
t.Fatalf("live list entry = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventCommandClosureErrorBranchesCoverage(t *testing.T) {
|
||||||
|
oldNormalize := eventNormalizeAs
|
||||||
|
oldList, oldStatus := eventRunPersonalList, eventRunPersonalStatus
|
||||||
|
oldCreds, oldFind, oldQuery := eventResolveAppCredentials, eventFindBus, eventQueryEntry
|
||||||
|
t.Cleanup(func() {
|
||||||
|
eventNormalizeAs = oldNormalize
|
||||||
|
eventRunPersonalList, eventRunPersonalStatus = oldList, oldStatus
|
||||||
|
eventResolveAppCredentials, eventFindBus, eventQueryEntry = oldCreds, oldFind, oldQuery
|
||||||
|
})
|
||||||
|
eventNormalizeAs = func(value string) (string, error) {
|
||||||
|
if strings.TrimSpace(value) == "app" {
|
||||||
|
return "app", nil
|
||||||
|
}
|
||||||
|
return normalizeEventAs(value)
|
||||||
|
}
|
||||||
|
personalList := newEventListCommand()
|
||||||
|
_ = personalList.Flags().Set("all", "true")
|
||||||
|
if err := personalList.RunE(personalList, nil); err == nil {
|
||||||
|
t.Fatal("personal list app flag succeeded")
|
||||||
|
}
|
||||||
|
personalStatus := newEventStatusCommand()
|
||||||
|
_ = personalStatus.Flags().Set("fail-on-orphan", "true")
|
||||||
|
if err := personalStatus.RunE(personalStatus, nil); err == nil {
|
||||||
|
t.Fatal("personal status app flag succeeded")
|
||||||
|
}
|
||||||
|
fail := errors.New("failure")
|
||||||
|
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail
|
||||||
|
}
|
||||||
|
appList := newEventListCommand()
|
||||||
|
_ = appList.Flags().Set("as", "app")
|
||||||
|
if err := appList.RunE(appList, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app list collect error = %v", err)
|
||||||
|
}
|
||||||
|
appStatus := newEventStatusCommand()
|
||||||
|
_ = appStatus.Flags().Set("as", "app")
|
||||||
|
if err := appStatus.RunE(appStatus, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("app status collect error = %v", err)
|
||||||
|
}
|
||||||
|
eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil
|
||||||
|
}
|
||||||
|
eventFindBus = func(string, string, string) *busctl.BusEntry {
|
||||||
|
return &busctl.BusEntry{State: busctl.BusStateRunning, Meta: &bus.Meta{ClientID: "client"}}
|
||||||
|
}
|
||||||
|
eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} }
|
||||||
|
appStatus = newEventStatusCommand()
|
||||||
|
appStatus.SetOut(io.Discard)
|
||||||
|
_ = appStatus.Flags().Set("as", "app")
|
||||||
|
_ = appStatus.Flags().Set("fail-on-orphan", "true")
|
||||||
|
if err := appStatus.RunE(appStatus, nil); err != nil {
|
||||||
|
t.Fatalf("healthy status with orphan gate = %v", err)
|
||||||
|
}
|
||||||
|
appStatus = newEventStatusCommand()
|
||||||
|
appStatus.SetOut(appFailWriter{err: errors.New("write")})
|
||||||
|
_ = appStatus.Flags().Set("as", "app")
|
||||||
|
_ = appStatus.Flags().Set("format", "json")
|
||||||
|
if err := appStatus.RunE(appStatus, nil); err == nil {
|
||||||
|
t.Fatal("status render failure should propagate")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,33 @@ type personalStreamSourceOptions struct {
|
|||||||
ClientIDOverride string
|
ClientIDOverride string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
personalResolveEventIdentity = resolvePersonalEventIdentity
|
||||||
|
personalEnsureSubscription = ensurePersonalSubscription
|
||||||
|
personalGetSubscription = (*personal.Client).GetSubscription
|
||||||
|
personalCreateSubscription = (*personal.Client).CreateSubscription
|
||||||
|
personalDeleteSubscription = (*personal.Client).DeleteSubscription
|
||||||
|
personalListSubscriptions = (*personal.Client).ListSubscriptions
|
||||||
|
personalUpsertRunState = personal.UpsertRunState
|
||||||
|
personalRemoveRunStates = personal.RemoveRunStates
|
||||||
|
personalLoadRunStates = personal.LoadRunStates
|
||||||
|
personalConsumeRun = consume.Run
|
||||||
|
personalValidateConsumeConfig = consume.ValidateConfig
|
||||||
|
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
|
||||||
|
personalNewStreamSource = newPersonalStreamSource
|
||||||
|
personalBusRun = bus.Run
|
||||||
|
personalFindBusByIdentity = busctl.FindBusByIdentity
|
||||||
|
personalQueryEntry = busctl.QueryEntry
|
||||||
|
personalQueryStatus = busctl.QueryStatus
|
||||||
|
personalStopBus = busctl.Stop
|
||||||
|
personalFindProcess = os.FindProcess
|
||||||
|
personalSignalProcess = (*os.Process).Signal
|
||||||
|
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||||
|
personalLoadTokenData = authpkg.LoadTokenData
|
||||||
|
personalClientID = authpkg.ClientID
|
||||||
|
personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict
|
||||||
|
)
|
||||||
|
|
||||||
func newEventSchemaCommand() *cobra.Command {
|
func newEventSchemaCommand() *cobra.Command {
|
||||||
var asIdentity string
|
var asIdentity string
|
||||||
var formatRaw string
|
var formatRaw string
|
||||||
@@ -116,13 +143,10 @@ func newEventSchemaCommand() *cobra.Command {
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(c *cobra.Command, args []string) error {
|
RunE: func(c *cobra.Command, args []string) error {
|
||||||
as, err := normalizeEventAs(asIdentity)
|
_, err := normalizeEventAs(asIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if as != "user" {
|
|
||||||
return fmt.Errorf("event schema is only supported with --as user")
|
|
||||||
}
|
|
||||||
def, ok := personal.Lookup(args[0])
|
def, ok := personal.Lookup(args[0])
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unknown personal event key %q", args[0])
|
return fmt.Errorf("unknown personal event key %q", args[0])
|
||||||
@@ -181,7 +205,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event consume --as user: %w", err)
|
return fmt.Errorf("event consume --as user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -223,18 +247,18 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
DryRun: true,
|
DryRun: true,
|
||||||
}
|
}
|
||||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||||
return consume.Run(ctx, cfg)
|
return personalConsumeRun(ctx, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
|
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event consume --as user: %w", err)
|
return fmt.Errorf("event consume --as user: %w", err)
|
||||||
}
|
}
|
||||||
if sub.SubscribeID == "" {
|
if sub.SubscribeID == "" {
|
||||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||||
}
|
}
|
||||||
if err := personal.UpsertRunState(workDir, personal.RunState{
|
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||||
SubscribeID: sub.SubscribeID,
|
SubscribeID: sub.SubscribeID,
|
||||||
EventKey: eventKey,
|
EventKey: eventKey,
|
||||||
RuleType: ruleType,
|
RuleType: ruleType,
|
||||||
@@ -245,8 +269,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||||
}
|
}
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
|
||||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||||
}
|
}
|
||||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||||
@@ -287,16 +311,16 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||||
workDir, filepath.Join(workDir, "bus.log"))
|
workDir, filepath.Join(workDir, "bus.log"))
|
||||||
}
|
}
|
||||||
if err := consume.ValidateConfig(cfg); err != nil {
|
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||||
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opts.Common.Foreground {
|
if opts.Common.Foreground {
|
||||||
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
|
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||||
ConfigDir: configDir,
|
ConfigDir: configDir,
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
TicketMode: opts.StreamTicketMode,
|
TicketMode: opts.StreamTicketMode,
|
||||||
@@ -319,13 +343,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
Source: src,
|
Source: src,
|
||||||
}
|
}
|
||||||
bus.ApplyEnvTuning(&busCfg)
|
bus.ApplyEnvTuning(&busCfg)
|
||||||
err = bus.Run(ctx, busCfg)
|
err = personalBusRun(ctx, busCfg)
|
||||||
if err != nil && !opts.Ephemeral {
|
if err != nil && !opts.Ephemeral {
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = consume.Run(ctx, cfg)
|
err = personalConsumeRun(ctx, cfg)
|
||||||
if err != nil && !opts.Ephemeral {
|
if err != nil && !opts.Ephemeral {
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
@@ -349,7 +373,7 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
|
|||||||
|
|
||||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||||
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
|
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
@@ -399,7 +423,7 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
|||||||
if opts.TTL > 0 {
|
if opts.TTL > 0 {
|
||||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||||
}
|
}
|
||||||
sub, err := client.CreateSubscription(ctx, req)
|
sub, err := personalCreateSubscription(client, ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
@@ -412,17 +436,17 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event status --as user: %w", err)
|
return fmt.Errorf("event status --as user: %w", err)
|
||||||
}
|
}
|
||||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||||
editionName := editionNameOrDefault()
|
editionName := editionNameOrDefault()
|
||||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||||
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
entry := personalFindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||||
var qs busctl.EntryStatus
|
var qs busctl.EntryStatus
|
||||||
if entry != nil {
|
if entry != nil {
|
||||||
qs = busctl.QueryEntry(*entry)
|
qs = personalQueryEntry(*entry)
|
||||||
} else {
|
} else {
|
||||||
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
|
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
|
||||||
WorkDir: workDir,
|
WorkDir: workDir,
|
||||||
@@ -444,7 +468,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
|||||||
if status == "" || status == "all" {
|
if status == "" || status == "all" {
|
||||||
status = ""
|
status = ""
|
||||||
}
|
}
|
||||||
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
|
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||||
Status: status,
|
Status: status,
|
||||||
EventKey: opts.EventKey,
|
EventKey: opts.EventKey,
|
||||||
SubscribeID: opts.SubscribeID,
|
SubscribeID: opts.SubscribeID,
|
||||||
@@ -547,7 +571,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
|
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event stop --as user: %w", err)
|
return fmt.Errorf("event stop --as user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -561,18 +585,18 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
|||||||
}
|
}
|
||||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||||
for _, id := range subscribeIDs {
|
for _, id := range subscribeIDs {
|
||||||
if err := client.DeleteSubscription(ctx, id); err != nil {
|
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
||||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
|
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||||
}
|
}
|
||||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining, err := personal.LoadRunStates(workDir)
|
remaining, err := personalLoadRunStates(workDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
|
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
|
||||||
}
|
}
|
||||||
@@ -582,7 +606,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
busState := "personal bus stopped"
|
busState := "personal bus stopped"
|
||||||
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||||
if errors.Is(err, busctl.ErrNotRunning) {
|
if errors.Is(err, busctl.ErrNotRunning) {
|
||||||
busState = "personal bus is not running"
|
busState = "personal bus is not running"
|
||||||
} else {
|
} else {
|
||||||
@@ -604,7 +628,7 @@ func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
|
|||||||
if !all {
|
if !all {
|
||||||
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
|
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
|
||||||
}
|
}
|
||||||
states, err := personal.LoadRunStates(workDir)
|
states, err := personalLoadRunStates(workDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -629,7 +653,7 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
|||||||
if ipcEndpoint == "" || len(targets) == 0 {
|
if ipcEndpoint == "" || len(targets) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
status, err := busctl.QueryStatus(ipcEndpoint)
|
status, err := personalQueryStatus(ipcEndpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -644,11 +668,11 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
|||||||
if _, ok := signalled[consumer.PID]; ok {
|
if _, ok := signalled[consumer.PID]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
proc, err := os.FindProcess(consumer.PID)
|
proc, err := personalFindProcess(consumer.PID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
|
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
|
||||||
}
|
}
|
||||||
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
if err := personalSignalProcess(proc, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
|
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
|
||||||
}
|
}
|
||||||
signalled[consumer.PID] = struct{}{}
|
signalled[consumer.PID] = struct{}{}
|
||||||
@@ -665,11 +689,11 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
|
||||||
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
|
accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return personal.Identity{}, err
|
return personal.Identity{}, err
|
||||||
}
|
}
|
||||||
tokenData, _ := authpkg.LoadTokenData(configDir)
|
tokenData, _ := personalLoadTokenData(configDir)
|
||||||
var corpID, userID, clientID, refreshToken string
|
var corpID, userID, clientID, refreshToken string
|
||||||
if tokenData != nil {
|
if tokenData != nil {
|
||||||
corpID = tokenData.CorpID
|
corpID = tokenData.CorpID
|
||||||
@@ -684,10 +708,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
|||||||
userID = resolveRuntimeDefault(ctx, "$currentUserId")
|
userID = resolveRuntimeDefault(ctx, "$currentUserId")
|
||||||
}
|
}
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
clientID = authpkg.ClientID()
|
clientID = personalClientID()
|
||||||
}
|
}
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
|
if id, _, _, _, err := personalResolveAppCredentialsStrict(configDir); err == nil {
|
||||||
clientID = id
|
clientID = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,7 +774,7 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
|||||||
clientID := opts.Identity.ClientID
|
clientID := opts.Identity.ClientID
|
||||||
clientSecret := ""
|
clientSecret := ""
|
||||||
if mode == "custom" {
|
if mode == "custom" {
|
||||||
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
|
resolvedID, secret, _, _, err := personalResolveAppCredentialsStrict(opts.ConfigDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -852,10 +876,7 @@ func personalEventStreamSourceID(raw string) string {
|
|||||||
if v := strings.TrimSpace(raw); v != "" {
|
if v := strings.TrimSpace(raw); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
|
return strings.TrimSpace(edition.PersonalEventSourceID())
|
||||||
return v
|
|
||||||
}
|
|
||||||
return "open"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func personalEventMCPBaseURL(configDir string) string {
|
func personalEventMCPBaseURL(configDir string) string {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||||
|
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPersonalEventRemainingSchemaAndSubscriptionCoverage(t *testing.T) {
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"known", "--as", "app"},
|
||||||
|
{"not-a-real-event"},
|
||||||
|
} {
|
||||||
|
cmd := newEventSchemaCommand()
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
if err := cmd.Execute(); err == nil {
|
||||||
|
t.Fatalf("schema args %#v succeeded", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldGet := personalGetSubscription
|
||||||
|
oldCreate := personalCreateSubscription
|
||||||
|
t.Cleanup(func() {
|
||||||
|
personalGetSubscription = oldGet
|
||||||
|
personalCreateSubscription = oldCreate
|
||||||
|
})
|
||||||
|
client := personal.NewClient("https://example.test", personal.Identity{})
|
||||||
|
wantErr := errors.New("subscription")
|
||||||
|
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
|
||||||
|
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("get subscription error = %v", err)
|
||||||
|
}
|
||||||
|
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||||
|
return &personal.Subscription{}, nil
|
||||||
|
}
|
||||||
|
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
|
||||||
|
t.Fatal("empty subscription event key succeeded")
|
||||||
|
}
|
||||||
|
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||||
|
return &personal.Subscription{EventKey: personal.EventFromUser}, nil
|
||||||
|
}
|
||||||
|
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil {
|
||||||
|
t.Fatal("private subscription event succeeded")
|
||||||
|
}
|
||||||
|
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||||
|
return &personal.Subscription{EventKey: personal.EventMention}, nil
|
||||||
|
}
|
||||||
|
if _, _, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || rule == "" {
|
||||||
|
t.Fatalf("default subscription rule = %q, %v", rule, err)
|
||||||
|
}
|
||||||
|
personalCreateSubscription = func(*personal.Client, context.Context, personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
|
||||||
|
return nil, wantErr
|
||||||
|
}
|
||||||
|
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("create subscription error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalEventRemainingConsumeCoverage(t *testing.T) {
|
||||||
|
oldIdentity := personalResolveEventIdentity
|
||||||
|
oldEnsure := personalEnsureSubscription
|
||||||
|
oldUpsert := personalUpsertRunState
|
||||||
|
oldDelete := personalDeleteSubscription
|
||||||
|
oldRemove := personalRemoveRunStates
|
||||||
|
oldConsume := personalConsumeRun
|
||||||
|
oldValidate := personalValidateConsumeConfig
|
||||||
|
oldConflict := personalValidateNoOutputConflict
|
||||||
|
oldNewSource := personalNewStreamSource
|
||||||
|
oldBusRun := personalBusRun
|
||||||
|
t.Cleanup(func() {
|
||||||
|
personalResolveEventIdentity = oldIdentity
|
||||||
|
personalEnsureSubscription = oldEnsure
|
||||||
|
personalUpsertRunState = oldUpsert
|
||||||
|
personalDeleteSubscription = oldDelete
|
||||||
|
personalRemoveRunStates = oldRemove
|
||||||
|
personalConsumeRun = oldConsume
|
||||||
|
personalValidateConsumeConfig = oldValidate
|
||||||
|
personalValidateNoOutputConflict = oldConflict
|
||||||
|
personalNewStreamSource = oldNewSource
|
||||||
|
personalBusRun = oldBusRun
|
||||||
|
})
|
||||||
|
|
||||||
|
wantErr := errors.New("consume")
|
||||||
|
cmd := newPersonalCoverageCommand()
|
||||||
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("identity error = %v", err)
|
||||||
|
}
|
||||||
|
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "source"}
|
||||||
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{RoutesRaw: []string{"bad-route"}}}); err == nil {
|
||||||
|
t.Fatal("invalid route succeeded")
|
||||||
|
}
|
||||||
|
personalConsumeRun = func(context.Context, consume.Config) error { return wantErr }
|
||||||
|
_ = cmd.Flags().Set("format", "table")
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{DryRun: true, FormatRaw: "bogus"}}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("dry-run consume error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||||
|
return nil, "", "", wantErr
|
||||||
|
}
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("ensure subscription error = %v", err)
|
||||||
|
}
|
||||||
|
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||||
|
return &personal.Subscription{}, personal.EventMention, "at", nil
|
||||||
|
}
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); err == nil || !strings.Contains(err.Error(), "empty subscribe_id") {
|
||||||
|
t.Fatalf("empty subscription = %v", err)
|
||||||
|
}
|
||||||
|
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||||
|
return &personal.Subscription{SubscribeID: "sub"}, personal.EventMention, "at", nil
|
||||||
|
}
|
||||||
|
personalUpsertRunState = func(string, personal.RunState) error { return wantErr }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("state upsert error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deletes := 0
|
||||||
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||||
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { deletes++; return nil }
|
||||||
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||||
|
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, DebugRawEvents: true}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("validate error = %v", err)
|
||||||
|
}
|
||||||
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||||
|
_ = cmd.Flags().Set("output", "file")
|
||||||
|
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("output conflict = %v", err)
|
||||||
|
}
|
||||||
|
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||||
|
|
||||||
|
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||||
|
return nil, wantErr
|
||||||
|
}
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
|
||||||
|
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
|
||||||
|
}
|
||||||
|
before := deletes
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
|
||||||
|
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
|
||||||
|
}
|
||||||
|
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
|
||||||
|
personalBusRun = func(context.Context, bus.Config) error { return wantErr }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("bus run error = %v", err)
|
||||||
|
}
|
||||||
|
personalConsumeRun = func(context.Context, consume.Config) error { return wantErr }
|
||||||
|
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("background consume error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalEventRemainingStatusStopAndInterruptCoverage(t *testing.T) {
|
||||||
|
oldIdentity := personalResolveEventIdentity
|
||||||
|
oldFindBus := personalFindBusByIdentity
|
||||||
|
oldQueryEntry := personalQueryEntry
|
||||||
|
oldList := personalListSubscriptions
|
||||||
|
oldDelete := personalDeleteSubscription
|
||||||
|
oldRemove := personalRemoveRunStates
|
||||||
|
oldLoad := personalLoadRunStates
|
||||||
|
oldStop := personalStopBus
|
||||||
|
oldQueryStatus := personalQueryStatus
|
||||||
|
oldFindProcess := personalFindProcess
|
||||||
|
oldSignal := personalSignalProcess
|
||||||
|
t.Cleanup(func() {
|
||||||
|
personalResolveEventIdentity = oldIdentity
|
||||||
|
personalFindBusByIdentity = oldFindBus
|
||||||
|
personalQueryEntry = oldQueryEntry
|
||||||
|
personalListSubscriptions = oldList
|
||||||
|
personalDeleteSubscription = oldDelete
|
||||||
|
personalRemoveRunStates = oldRemove
|
||||||
|
personalLoadRunStates = oldLoad
|
||||||
|
personalStopBus = oldStop
|
||||||
|
personalQueryStatus = oldQueryStatus
|
||||||
|
personalFindProcess = oldFindProcess
|
||||||
|
personalSignalProcess = oldSignal
|
||||||
|
})
|
||||||
|
|
||||||
|
wantErr := errors.New("status-stop")
|
||||||
|
cmd := newPersonalCoverageCommand()
|
||||||
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr }
|
||||||
|
if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("status identity error = %v", err)
|
||||||
|
}
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("stop identity error = %v", err)
|
||||||
|
}
|
||||||
|
identity := personal.Identity{ClientID: "client", SourceID: "source"}
|
||||||
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
||||||
|
entry := &busctl.BusEntry{State: busctl.BusStateRunning}
|
||||||
|
personalFindBusByIdentity = func(string, string, dwsevent.SourceKind, string) *busctl.BusEntry { return entry }
|
||||||
|
personalQueryEntry = func(busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: *entry} }
|
||||||
|
personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) {
|
||||||
|
return nil, wantErr
|
||||||
|
}
|
||||||
|
if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("status list error = %v", err)
|
||||||
|
}
|
||||||
|
personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := runPersonalEventStatus(cmd, personalStatusOptions{Status: "all", Format: "json"}); err != nil {
|
||||||
|
t.Fatalf("status entry JSON = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{All: true}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("stop targets error = %v", err)
|
||||||
|
}
|
||||||
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return wantErr }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("delete error = %v", err)
|
||||||
|
}
|
||||||
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||||
|
personalRemoveRunStates = func(string, []string) error { return wantErr }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("remove error = %v", err)
|
||||||
|
}
|
||||||
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||||
|
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return nil, wantErr }
|
||||||
|
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("remaining state error = %v", err)
|
||||||
|
}
|
||||||
|
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) {
|
||||||
|
return &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{{SubscribeID: "sub", PID: 123}}}, nil
|
||||||
|
}
|
||||||
|
personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr }
|
||||||
|
personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
|
||||||
|
t.Fatalf("interrupt warning stop = %v", err)
|
||||||
|
}
|
||||||
|
personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
|
||||||
|
t.Fatalf("remaining bus stop = %v", err)
|
||||||
|
}
|
||||||
|
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, nil }
|
||||||
|
personalStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil {
|
||||||
|
t.Fatalf("not running stop = %v", err)
|
||||||
|
}
|
||||||
|
personalStopBus = func(busctl.StopConfig) error { return wantErr }
|
||||||
|
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("bus stop error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
personalLoadRunStates = func(string) ([]personal.RunState, error) {
|
||||||
|
return []personal.RunState{{}, {SubscribeID: "b"}, {SubscribeID: "a"}}, nil
|
||||||
|
}
|
||||||
|
if got, err := personalStopTargets("", "", true); err != nil || strings.Join(got, ",") != "a,b" {
|
||||||
|
t.Fatalf("stop target filtering = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
status := &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{
|
||||||
|
{SubscribeID: "other", PID: 1},
|
||||||
|
{SubscribeID: "sub", PID: 0},
|
||||||
|
{SubscribeID: "sub", PID: os.Getpid()},
|
||||||
|
{SubscribeID: "sub", PID: 123},
|
||||||
|
{SubscribeID: "sub", PID: 123},
|
||||||
|
}}
|
||||||
|
personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return status, nil }
|
||||||
|
personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr }
|
||||||
|
if err := interruptPersonalConsumers("ipc", []string{" sub ", ""}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("find process error = %v", err)
|
||||||
|
}
|
||||||
|
proc := &os.Process{}
|
||||||
|
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||||
|
personalSignalProcess = func(*os.Process, os.Signal) error { return wantErr }
|
||||||
|
if err := interruptPersonalConsumers("ipc", []string{"sub"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("signal process error = %v", err)
|
||||||
|
}
|
||||||
|
personalSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
|
||||||
|
if err := interruptPersonalConsumers("ipc", []string{"sub"}); err != nil {
|
||||||
|
t.Fatalf("completed process signal = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalEventRemainingIdentityAndSourceCoverage(t *testing.T) {
|
||||||
|
oldAux := personalResolveAuxiliaryAccessToken
|
||||||
|
oldLoad := personalLoadTokenData
|
||||||
|
oldClientID := personalClientID
|
||||||
|
oldCredentials := personalResolveAppCredentialsStrict
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
personalResolveAuxiliaryAccessToken = oldAux
|
||||||
|
personalLoadTokenData = oldLoad
|
||||||
|
personalClientID = oldClientID
|
||||||
|
personalResolveAppCredentialsStrict = oldCredentials
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
})
|
||||||
|
wantErr := errors.New("identity")
|
||||||
|
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "", wantErr }
|
||||||
|
if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("aux token error = %v", err)
|
||||||
|
}
|
||||||
|
personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "access", nil }
|
||||||
|
personalLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||||
|
personalClientID = func() string { return "" }
|
||||||
|
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "resolved", "secret", "", "", nil
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn {
|
||||||
|
return map[string]edition.RuntimeDefaultFn{
|
||||||
|
"$corpId": func(context.Context) (string, bool) { return " corp ", true },
|
||||||
|
"$currentUserId": func(context.Context) (string, bool) { return " user ", true },
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
if got, err := resolvePersonalEventIdentity(context.Background(), "", "source"); err != nil || got.ClientID != "resolved" || got.CorpID != "corp" {
|
||||||
|
t.Fatalf("resolved identity = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "", "", "", "", wantErr
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); err == nil {
|
||||||
|
t.Fatal("missing client ID succeeded")
|
||||||
|
}
|
||||||
|
if got := resolveRuntimeDefault(context.Background(), "missing"); got != "" {
|
||||||
|
t.Fatalf("missing runtime default = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: "", Identity: personal.Identity{}, TicketMode: "custom"}); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("custom credential error = %v", err)
|
||||||
|
}
|
||||||
|
personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) {
|
||||||
|
return "resolved", "secret", "", "", nil
|
||||||
|
}
|
||||||
|
if src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{Identity: personal.Identity{AccessToken: "token", SourceID: "source"}, TicketMode: "custom"}); err != nil || src == nil {
|
||||||
|
t.Fatalf("custom resolved source = %#v, %v", src, err)
|
||||||
|
}
|
||||||
|
if got := personalEventStreamSourceID(""); got != "open" {
|
||||||
|
t.Fatalf("default stream source = %q", got)
|
||||||
|
}
|
||||||
|
if got := configuredMCPBaseURL(""); got != "" {
|
||||||
|
t.Fatalf("default missing configured MCP = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPersonalCoverageCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "event"}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
cmd.SetOut(&bytes.Buffer{})
|
||||||
|
cmd.SetErr(&bytes.Buffer{})
|
||||||
|
cmd.Flags().String("format", "table", "")
|
||||||
|
cmd.Flags().String("output", "", "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = time.Second
|
||||||
@@ -23,6 +23,20 @@ import (
|
|||||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type accessTokenGetter interface {
|
||||||
|
GetAccessToken(context.Context) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
markAccessTokenStale = authpkg.MarkAccessTokenStale
|
||||||
|
newRefreshProvider = func(configDir string) accessTokenGetter {
|
||||||
|
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||||
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
|
// ForceRefreshAccessToken forces a single refresh_token exchange and returns
|
||||||
// the new access_token. It is intended for callers that have observed a
|
// the new access_token. It is intended for callers that have observed a
|
||||||
// server-side rejection (HTTP 401 or business code such as
|
// server-side rejection (HTTP 401 or business code such as
|
||||||
@@ -43,12 +57,10 @@ func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, err
|
|||||||
if strings.TrimSpace(configDir) == "" {
|
if strings.TrimSpace(configDir) == "" {
|
||||||
return "", fmt.Errorf("config directory is empty")
|
return "", fmt.Errorf("config directory is empty")
|
||||||
}
|
}
|
||||||
if err := authpkg.MarkAccessTokenStale(configDir); err != nil {
|
if err := markAccessTokenStale(configDir); err != nil {
|
||||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
return "", fmt.Errorf("mark access token stale: %w", err)
|
||||||
}
|
}
|
||||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
provider := newRefreshProvider(configDir)
|
||||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
|
||||||
tok, err := provider.GetAccessToken(ctx)
|
tok, err := provider.GetAccessToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ const (
|
|||||||
|
|
||||||
var openBrowserFunc = tryOpenBrowser
|
var openBrowserFunc = tryOpenBrowser
|
||||||
|
|
||||||
|
var (
|
||||||
|
patAuthorizationTimeout = PatAuthRetryTimeout
|
||||||
|
patAuthorizationPollInterval = PatAuthPollInterval
|
||||||
|
patLoadTokenData = authpkg.LoadTokenData
|
||||||
|
patWaitForAuthorization = WaitForPatAuthorization
|
||||||
|
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
||||||
|
patSaveAppConfig = authpkg.SaveAppConfig
|
||||||
|
patExchangeCodeForToken = authpkg.ExchangeCodeForToken
|
||||||
|
patSaveTokenData = authpkg.SaveTokenData
|
||||||
|
patSleep = time.Sleep
|
||||||
|
patPollHTTPDo = (*http.Client).Do
|
||||||
|
patPollNewRequest = http.NewRequestWithContext
|
||||||
|
patBrowserOpenCommand = browserOpenCommand
|
||||||
|
)
|
||||||
|
|
||||||
type patSuppressBrowserOpenKeyType struct{}
|
type patSuppressBrowserOpenKeyType struct{}
|
||||||
|
|
||||||
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
|
||||||
@@ -236,10 +251,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
|||||||
}
|
}
|
||||||
data["openBrowser"] = openBrowser
|
data["openBrowser"] = openBrowser
|
||||||
|
|
||||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||||
if err != nil {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
return string(encoded)
|
return string(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,9 +268,9 @@ func patAuthorizationURIFromData(data map[string]any) string {
|
|||||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
||||||
timeout := PatAuthRetryTimeout
|
timeout := patAuthorizationTimeout
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
pollTicker := time.NewTicker(PatAuthPollInterval)
|
pollTicker := time.NewTicker(patAuthorizationPollInterval)
|
||||||
defer pollTicker.Stop()
|
defer pollTicker.Stop()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
@@ -286,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
|||||||
remaining := time.Until(deadline).Truncate(time.Second)
|
remaining := time.Until(deadline).Truncate(time.Second)
|
||||||
|
|
||||||
// Check if token is now valid
|
// Check if token is now valid
|
||||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
tokenData, err := patLoadTokenData(configDir)
|
||||||
if err == nil && tokenData != nil {
|
if err == nil && tokenData != nil {
|
||||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
||||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||||
@@ -323,7 +335,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
|||||||
PrintPatAuthError(output, scopeErr)
|
PrintPatAuthError(output, scopeErr)
|
||||||
|
|
||||||
// Wait for user to complete authorization
|
// Wait for user to complete authorization
|
||||||
authorized := WaitForPatAuthorization(ctx, configDir, output)
|
authorized := patWaitForAuthorization(ctx, configDir, output)
|
||||||
if !authorized {
|
if !authorized {
|
||||||
return executor.Result{}, apperrors.NewAuth(
|
return executor.Result{}, apperrors.NewAuth(
|
||||||
"等待用户授权超时",
|
"等待用户授权超时",
|
||||||
@@ -579,7 +591,7 @@ func handlePatAuthCheck(
|
|||||||
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
status, authCode, err := pollPatDeviceFlowWithInterval(
|
status, authCode, err := patPollDeviceFlowWithInterval(
|
||||||
pollCtx, patData.Data.FlowID, configDir, output,
|
pollCtx, patData.Data.FlowID, configDir, output,
|
||||||
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
resolvePATPollInterval(patData.Data.PollIntervalSecs),
|
||||||
)
|
)
|
||||||
@@ -594,7 +606,7 @@ func handlePatAuthCheck(
|
|||||||
fmt.Fprintln(output)
|
fmt.Fprintln(output)
|
||||||
|
|
||||||
if appCfg != nil {
|
if appCfg != nil {
|
||||||
if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil {
|
if err := patSaveAppConfig(configDir, appCfg); err != nil {
|
||||||
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
slog.Warn("failed to persist approved app config from PAT", "error", err)
|
||||||
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err)
|
||||||
}
|
}
|
||||||
@@ -603,12 +615,12 @@ func handlePatAuthCheck(
|
|||||||
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
// Exchange authCode for a fresh access token (mirrors device_flow loginOnce).
|
||||||
if authCode != "" {
|
if authCode != "" {
|
||||||
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true)
|
||||||
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
|
tokenData, exchErr := patExchangeCodeForToken(ctx, configDir, authCode)
|
||||||
if exchErr != nil {
|
if exchErr != nil {
|
||||||
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
|
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
|
||||||
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
|
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
|
||||||
} else {
|
} else {
|
||||||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
if err := patSaveTokenData(configDir, tokenData); err != nil {
|
||||||
slog.Warn("PAT retry: failed to save new token", "error", err)
|
slog.Warn("PAT retry: failed to save new token", "error", err)
|
||||||
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
|
||||||
} else {
|
} else {
|
||||||
@@ -633,7 +645,7 @@ func handlePatAuthCheck(
|
|||||||
// Workaround: brief delay to let server-side authorization state propagate
|
// Workaround: brief delay to let server-side authorization state propagate
|
||||||
// before retrying. Without this the retry may use stale credentials.
|
// before retrying. Without this the retry may use stale credentials.
|
||||||
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
|
||||||
time.Sleep(1 * time.Second)
|
patSleep(1 * time.Second)
|
||||||
|
|
||||||
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
// Retry the original invocation with pat-retrying flag to prevent recursion.
|
||||||
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
|
||||||
@@ -705,10 +717,7 @@ func enrichPATErrorForHostControl(raw string) string {
|
|||||||
apperrors.ApplyHostMutations(payload)
|
apperrors.ApplyHostMutations(payload)
|
||||||
|
|
||||||
// stderr JSON MUST be single-line.
|
// stderr JSON MUST be single-line.
|
||||||
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
|
encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload)
|
||||||
if err != nil {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
return string(encoded)
|
return string(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,10 +748,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
|
|||||||
"data": data,
|
"data": data,
|
||||||
}
|
}
|
||||||
// stderr JSON MUST be single-line.
|
// stderr JSON MUST be single-line.
|
||||||
b, err := jsonutil.Marshal(payload)
|
b, _ := jsonutil.Marshal(payload)
|
||||||
if err != nil {
|
|
||||||
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
|
|
||||||
}
|
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +809,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
|||||||
pollCount++
|
pollCount++
|
||||||
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
|
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
|
req, err := patPollNewRequest(ctx, http.MethodGet, pollURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||||
continue
|
continue
|
||||||
@@ -811,7 +817,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
|||||||
if accessToken != "" {
|
if accessToken != "" {
|
||||||
req.Header.Set("x-user-access-token", accessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
}
|
}
|
||||||
resp, err := noRedirectClient.Do(req)
|
resp, err := patPollHTTPDo(noRedirectClient, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("PAT poll: request failed", "error", err)
|
slog.Debug("PAT poll: request failed", "error", err)
|
||||||
continue // transient network error, keep polling
|
continue // transient network error, keep polling
|
||||||
@@ -858,9 +864,6 @@ func resolvePATPollInterval(seconds int) time.Duration {
|
|||||||
return patPollInterval
|
return patPollInterval
|
||||||
}
|
}
|
||||||
interval := time.Duration(seconds) * time.Second
|
interval := time.Duration(seconds) * time.Second
|
||||||
if interval < time.Second {
|
|
||||||
return time.Second
|
|
||||||
}
|
|
||||||
if interval > patMaxPollInterval {
|
if interval > patMaxPollInterval {
|
||||||
return patMaxPollInterval
|
return patMaxPollInterval
|
||||||
}
|
}
|
||||||
@@ -882,7 +885,7 @@ func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
|||||||
|
|
||||||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||||||
func tryOpenBrowser(rawURL string) error {
|
func tryOpenBrowser(rawURL string) error {
|
||||||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
cmd := patBrowserOpenCommand(runtime.GOOS, rawURL)
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPATRetryRemainingPureAndWaitCoverage(t *testing.T) {
|
||||||
|
typed := apperrors.NewAPI("ordinary", apperrors.WithHint("insufficient_scope"))
|
||||||
|
if !isPatScopeError(typed) {
|
||||||
|
t.Fatal("typed insufficient_scope was not recognized")
|
||||||
|
}
|
||||||
|
typed = apperrors.NewAPI("ordinary", apperrors.WithReason("reason"))
|
||||||
|
if got := extractPatScopeError(typed); !strings.Contains(got.Message, "reason") {
|
||||||
|
t.Fatalf("typed scope message = %q", got.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
PrintPatAuthError(&out, &PatScopeError{Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login"})
|
||||||
|
if !strings.Contains(out.String(), "dws auth login") {
|
||||||
|
t.Fatalf("PAT output = %q", out.String())
|
||||||
|
}
|
||||||
|
if wantsStructuredPATOutputFromRunner(runnerCoverageFallback{}) {
|
||||||
|
t.Fatal("non-runtime runner requested structured PAT output")
|
||||||
|
}
|
||||||
|
if got := enrichPATErrorWithOpenBrowser("", true); got != "" {
|
||||||
|
t.Fatalf("empty enriched PAT = %q", got)
|
||||||
|
}
|
||||||
|
if got := enrichPATErrorWithOpenBrowser("not-json", true); got != "not-json" {
|
||||||
|
t.Fatalf("malformed enriched PAT = %q", got)
|
||||||
|
}
|
||||||
|
if got := enrichPATErrorWithOpenBrowser(`{"code":"x"}`, true); !strings.Contains(got, "openBrowser") {
|
||||||
|
t.Fatalf("missing data enrichment = %q", got)
|
||||||
|
}
|
||||||
|
if got := patAuthorizationURIFromData(map[string]any{"authorizationUrl": " final "}); got != "final" {
|
||||||
|
t.Fatalf("authorization URI = %q", got)
|
||||||
|
}
|
||||||
|
if err := openPATAuthorizationURI(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldTimeout := patAuthorizationTimeout
|
||||||
|
oldInterval := patAuthorizationPollInterval
|
||||||
|
oldLoad := patLoadTokenData
|
||||||
|
t.Cleanup(func() {
|
||||||
|
patAuthorizationTimeout = oldTimeout
|
||||||
|
patAuthorizationPollInterval = oldInterval
|
||||||
|
patLoadTokenData = oldLoad
|
||||||
|
})
|
||||||
|
patAuthorizationTimeout = 50 * time.Millisecond
|
||||||
|
patAuthorizationPollInterval = time.Millisecond
|
||||||
|
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
if !WaitForPatAuthorization(context.Background(), "", &out) {
|
||||||
|
t.Fatal("valid token did not authorize")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
out.Reset()
|
||||||
|
if WaitForPatAuthorization(ctx, "", &out) {
|
||||||
|
t.Fatal("cancelled authorization succeeded")
|
||||||
|
}
|
||||||
|
patAuthorizationTimeout = time.Millisecond
|
||||||
|
patAuthorizationPollInterval = time.Hour
|
||||||
|
out.Reset()
|
||||||
|
if WaitForPatAuthorization(context.Background(), "", &out) {
|
||||||
|
t.Fatal("timed out authorization succeeded")
|
||||||
|
}
|
||||||
|
patAuthorizationTimeout = 5 * time.Millisecond
|
||||||
|
patAuthorizationPollInterval = time.Millisecond
|
||||||
|
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||||
|
out.Reset()
|
||||||
|
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
|
||||||
|
t.Fatalf("invalid-token polling output = %q", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPATRetryRemainingOrchestrationCoverage(t *testing.T) {
|
||||||
|
oldWait := patWaitForAuthorization
|
||||||
|
oldPoll := patPollDeviceFlowWithInterval
|
||||||
|
oldSaveApp := patSaveAppConfig
|
||||||
|
oldExchange := patExchangeCodeForToken
|
||||||
|
oldSaveToken := patSaveTokenData
|
||||||
|
oldSleep := patSleep
|
||||||
|
oldOpen := openBrowserFunc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
patWaitForAuthorization = oldWait
|
||||||
|
patPollDeviceFlowWithInterval = oldPoll
|
||||||
|
patSaveAppConfig = oldSaveApp
|
||||||
|
patExchangeCodeForToken = oldExchange
|
||||||
|
patSaveTokenData = oldSaveToken
|
||||||
|
patSleep = oldSleep
|
||||||
|
openBrowserFunc = oldOpen
|
||||||
|
})
|
||||||
|
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
|
||||||
|
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
|
||||||
|
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
|
||||||
|
t.Fatal("PAT retry timeout succeeded")
|
||||||
|
}
|
||||||
|
wantErr := errors.New("runner failed")
|
||||||
|
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
|
||||||
|
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("authorized retry = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
|
||||||
|
if err := runDirectPATAuthCheck(context.Background(), nil, patErr, nil, io.Discard); !errors.Is(err, patErr) {
|
||||||
|
t.Fatalf("nil direct retry = %v", err)
|
||||||
|
}
|
||||||
|
if err := runDirectPATAuthCheckWithMode(context.Background(), nil, patErr, nil, io.Discard, true); !errors.Is(err, patErr) {
|
||||||
|
t.Fatalf("nil retry mode = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
badRetry := errors.New("retry callback")
|
||||||
|
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||||
|
return authpkg.StatusApproved, "", nil
|
||||||
|
}
|
||||||
|
patSleep = func(time.Duration) {}
|
||||||
|
if err := runDirectPATAuthCheck(context.Background(), nil, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, func(context.Context) error { return badRetry }, io.Discard); !errors.Is(err, badRetry) {
|
||||||
|
t.Fatalf("direct retry callback = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
malformedPAT := &apperrors.PATError{RawJSON: `{`}
|
||||||
|
if _, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, executor.Invocation{}, malformedPAT, t.TempDir(), io.Discard); !errors.Is(err, malformedPAT) {
|
||||||
|
t.Fatalf("malformed PAT handler = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
openBrowserFunc = func(string) error { return nil }
|
||||||
|
for _, raw := range []string{
|
||||||
|
`{"code":"x","data":{"flowId":"f","authUrl":"https://auth.test","desc":"authorize"}}`,
|
||||||
|
`{"code":"x","data":{"flowId":"f","authorizationUrl":"https://auth2.test"}}`,
|
||||||
|
} {
|
||||||
|
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||||
|
return "", "", wantErr
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(context.Background(), patSuppressBrowserOpenKey, true)
|
||||||
|
if _, err := handlePatAuthCheck(ctx, &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: raw}, t.TempDir(), io.Discard); err == nil {
|
||||||
|
t.Fatal("poll failure returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses := []string{authpkg.StatusRejected, authpkg.StatusExpired, authpkg.StatusCancelled, "UNKNOWN"}
|
||||||
|
for _, status := range statuses {
|
||||||
|
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||||
|
return status, "", nil
|
||||||
|
}
|
||||||
|
if _, err := handlePatAuthCheck(context.WithValue(context.Background(), patSuppressBrowserOpenKey, true), &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err == nil {
|
||||||
|
t.Fatalf("status %s returned nil", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) {
|
||||||
|
return authpkg.StatusApproved, "code", nil
|
||||||
|
}
|
||||||
|
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return wantErr }
|
||||||
|
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) { return nil, wantErr }
|
||||||
|
skip := executor.Invocation{Params: map[string]any{"retryAfterApproval": false}}
|
||||||
|
if got, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, skip, &apperrors.PATError{RawJSON: patRaw("f", "client", "secret")}, t.TempDir(), io.Discard); err != nil || !got.Invocation.Implemented {
|
||||||
|
t.Fatalf("approved skip = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil }
|
||||||
|
patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{AccessToken: "token"}, nil
|
||||||
|
}
|
||||||
|
patSaveTokenData = func(string, *authpkg.TokenData) error { return wantErr }
|
||||||
|
r := &runtimeRunner{fallback: runnerCoverageFallback{result: executor.Result{Response: map[string]any{"ok": true}}}}
|
||||||
|
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "client", "")}, t.TempDir(), io.Discard); err != nil {
|
||||||
|
t.Fatalf("approved retry with save failure = %v", err)
|
||||||
|
}
|
||||||
|
patSaveTokenData = func(string, *authpkg.TokenData) error { return nil }
|
||||||
|
if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err != nil {
|
||||||
|
t.Fatalf("approved retry = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inv := range []executor.Invocation{{}, {Params: map[string]any{}}, {Params: map[string]any{"retryAfterApproval": "no"}}, {Params: map[string]any{"retryAfterApproval": true}}} {
|
||||||
|
if shouldSkipPATRetryAfterApproval(inv) {
|
||||||
|
t.Fatalf("unexpected skip for %#v", inv.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := enrichPATErrorForHostControl(""); got != "" {
|
||||||
|
t.Fatalf("empty host PAT = %q", got)
|
||||||
|
}
|
||||||
|
if got := enrichPATErrorForHostControl("bad"); got != "bad" {
|
||||||
|
t.Fatalf("bad host PAT = %q", got)
|
||||||
|
}
|
||||||
|
if got := enrichPATErrorForHostControl(`{"value":1}`); !strings.Contains(got, "value") {
|
||||||
|
t.Fatalf("generic host PAT = %q", got)
|
||||||
|
}
|
||||||
|
if _, err := marshalSingleLineJSONNoHTMLEscape(map[string]any{"bad": func() {}}); err == nil {
|
||||||
|
t.Fatal("unsupported JSON value succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func patRaw(flowID, clientID, secret string) string {
|
||||||
|
return `{"code":"x","data":{"desc":"authorize","flowId":"` + flowID + `","uri":"https://auth.test","clientId":"` + clientID + `","clientSecret":"` + secret + `"}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
|
||||||
|
oldDo := patPollHTTPDo
|
||||||
|
oldRequest := patPollNewRequest
|
||||||
|
oldLoad := patLoadTokenData
|
||||||
|
oldBrowser := patBrowserOpenCommand
|
||||||
|
t.Cleanup(func() {
|
||||||
|
patPollHTTPDo = oldDo
|
||||||
|
patPollNewRequest = oldRequest
|
||||||
|
patLoadTokenData = oldLoad
|
||||||
|
patBrowserOpenCommand = oldBrowser
|
||||||
|
})
|
||||||
|
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
|
||||||
|
cancelled, cancelNow := context.WithCancel(context.Background())
|
||||||
|
cancelNow()
|
||||||
|
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
|
||||||
|
t.Fatalf("zero-interval cancelled poll = %q, %v", status, err)
|
||||||
|
}
|
||||||
|
expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||||
|
defer cancelExpired()
|
||||||
|
if status, _, err := pollPatDeviceFlowWithInterval(expired, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusExpired {
|
||||||
|
t.Fatalf("expired-context poll = %q, %v", status, err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
cancel()
|
||||||
|
return nil, errors.New("network")
|
||||||
|
}
|
||||||
|
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
|
||||||
|
t.Fatalf("network poll = %q, %v", status, err)
|
||||||
|
}
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
cancel()
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{"))}, nil
|
||||||
|
}
|
||||||
|
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
|
||||||
|
t.Fatalf("malformed poll = %q, %v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
patPollNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) {
|
||||||
|
cancel()
|
||||||
|
return nil, errors.New("request")
|
||||||
|
}
|
||||||
|
if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled {
|
||||||
|
t.Fatalf("invalid request poll = %q, %v", status, err)
|
||||||
|
}
|
||||||
|
patPollNewRequest = oldRequest
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
t.Setenv("DWS_DEBUG_PAT_POLL", "1")
|
||||||
|
printPATPollDebugResponse(&out, 500, nil)
|
||||||
|
if !strings.Contains(out.String(), "empty body") {
|
||||||
|
t.Fatalf("empty debug response = %q", out.String())
|
||||||
|
}
|
||||||
|
for _, goos := range []string{"darwin", "linux", "windows", "plan9"} {
|
||||||
|
_ = browserOpenCommand(goos, "https://example.test")
|
||||||
|
}
|
||||||
|
patBrowserOpenCommand = func(string, string) *exec.Cmd { return nil }
|
||||||
|
if err := tryOpenBrowser("https://example.test"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
patBrowserOpenCommand = func(string, string) *exec.Cmd { return exec.Command("definitely-not-a-real-dws-command") }
|
||||||
|
if err := tryOpenBrowser("https://example.test"); err == nil {
|
||||||
|
t.Fatal("missing browser command started")
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-12
@@ -26,6 +26,18 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
pluginInstallFromGit = (*plugin.Loader).InstallFromGit
|
||||||
|
pluginStat = os.Stat
|
||||||
|
pluginMkdirAll = os.MkdirAll
|
||||||
|
pluginWriteFile = os.WriteFile
|
||||||
|
pluginAbs = filepath.Abs
|
||||||
|
pluginRegisterDev = (*plugin.Loader).RegisterDevPlugin
|
||||||
|
pluginListInstalled = (*plugin.Loader).ListInstalled
|
||||||
|
pluginParseManifest = plugin.ParseManifest
|
||||||
|
pluginBuild = plugin.BuildPlugin
|
||||||
|
)
|
||||||
|
|
||||||
func newPluginCommand() *cobra.Command {
|
func newPluginCommand() *cobra.Command {
|
||||||
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
|
pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理"))
|
||||||
|
|
||||||
@@ -98,7 +110,7 @@ func newPluginInstallCommand() *cobra.Command {
|
|||||||
loader := plugin.NewLoader(RawVersion())
|
loader := plugin.NewLoader(RawVersion())
|
||||||
|
|
||||||
if gitURL != "" {
|
if gitURL != "" {
|
||||||
p, err := loader.InstallFromGit(gitURL)
|
p, err := pluginInstallFromGit(loader, gitURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err))
|
||||||
}
|
}
|
||||||
@@ -247,7 +259,7 @@ func newPluginCreateCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Join(".", name)
|
dir := filepath.Join(".", name)
|
||||||
if _, err := os.Stat(dir); err == nil {
|
if _, err := pluginStat(dir); err == nil {
|
||||||
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
|
return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +270,7 @@ func newPluginCreateCommand() *cobra.Command {
|
|||||||
filepath.Join(dir, "hooks"),
|
filepath.Join(dir, "hooks"),
|
||||||
}
|
}
|
||||||
for _, d := range dirs {
|
for _, d := range dirs {
|
||||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
if err := pluginMkdirAll(d, 0o755); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +298,7 @@ func newPluginCreateCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
`, name, desc, pluginType, RawVersion(), name)
|
`, name, desc, pluginType, RawVersion(), name)
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
if err := pluginWriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +329,7 @@ Use this skill when the user mentions:
|
|||||||
- Conversion rules
|
- Conversion rules
|
||||||
`, name, desc, RawVersion(), name, name)
|
`, name, desc, RawVersion(), name, name)
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
if err := pluginWriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +338,7 @@ Use this skill when the user mentions:
|
|||||||
"hooks": []
|
"hooks": []
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
if err := pluginWriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +389,7 @@ to unregister.`,
|
|||||||
|
|
||||||
// Register dev plugin
|
// Register dev plugin
|
||||||
dir := args[0]
|
dir := args[0]
|
||||||
absDir, err := filepath.Abs(dir)
|
absDir, err := pluginAbs(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||||
}
|
}
|
||||||
@@ -391,7 +403,7 @@ to unregister.`,
|
|||||||
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil {
|
if err := pluginRegisterDev(loader, m.Name, absDir); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,10 +594,10 @@ func newPluginConfigUnsetCommand() *cobra.Command {
|
|||||||
|
|
||||||
// loadDeclaredUserConfig loads the userConfig section from a plugin's manifest.
|
// loadDeclaredUserConfig loads the userConfig section from a plugin's manifest.
|
||||||
func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem {
|
func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem {
|
||||||
plugins := loader.ListInstalled()
|
plugins := pluginListInstalled(loader)
|
||||||
for _, p := range plugins {
|
for _, p := range plugins {
|
||||||
if p.Name == pluginName {
|
if p.Name == pluginName {
|
||||||
m, err := plugin.ParseManifest(filepath.Join(p.Path, "plugin.json"))
|
m, err := pluginParseManifest(filepath.Join(p.Path, "plugin.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -626,7 +638,7 @@ The build configuration is read from the "build" field in plugin.json:
|
|||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
dir := args[0]
|
dir := args[0]
|
||||||
absDir, err := filepath.Abs(dir)
|
absDir, err := pluginAbs(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err))
|
||||||
}
|
}
|
||||||
@@ -646,7 +658,7 @@ The build configuration is read from the "build" field in plugin.json:
|
|||||||
" }", m.Name))
|
" }", m.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := plugin.BuildPlugin(absDir); err != nil {
|
if err := pluginBuild(absDir); err != nil {
|
||||||
return apperrors.NewInternal(err.Error())
|
return apperrors.NewInternal(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pluginCoverageRun(cmd *cobra.Command, args ...string) (string, error) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
cmd.SetOut(out)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SilenceErrors = true
|
||||||
|
cmd.SilenceUsage = true
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginCommandRemainingCoverage(t *testing.T) {
|
||||||
|
oldGit := pluginInstallFromGit
|
||||||
|
oldStat := pluginStat
|
||||||
|
oldMkdir := pluginMkdirAll
|
||||||
|
oldWrite := pluginWriteFile
|
||||||
|
oldAbs := pluginAbs
|
||||||
|
oldRegister := pluginRegisterDev
|
||||||
|
oldBuild := pluginBuild
|
||||||
|
oldList, oldParse := pluginListInstalled, pluginParseManifest
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pluginInstallFromGit = oldGit
|
||||||
|
pluginStat = oldStat
|
||||||
|
pluginMkdirAll = oldMkdir
|
||||||
|
pluginWriteFile = oldWrite
|
||||||
|
pluginAbs = oldAbs
|
||||||
|
pluginRegisterDev = oldRegister
|
||||||
|
pluginBuild = oldBuild
|
||||||
|
pluginListInstalled, pluginParseManifest = oldList, oldParse
|
||||||
|
})
|
||||||
|
home := t.TempDir()
|
||||||
|
work := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
oldWD, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(work); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||||
|
fail := errors.New("failure")
|
||||||
|
pluginListInstalled = func(*plugin.Loader) []plugin.PluginInfo {
|
||||||
|
return []plugin.PluginInfo{{Name: "broken", Path: "missing"}}
|
||||||
|
}
|
||||||
|
pluginParseManifest = func(string) (*plugin.Manifest, error) { return nil, fail }
|
||||||
|
if got := loadDeclaredUserConfig(plugin.NewLoader(RawVersion()), "broken"); got != nil {
|
||||||
|
t.Fatalf("broken declared config = %#v", got)
|
||||||
|
}
|
||||||
|
pluginListInstalled, pluginParseManifest = oldList, oldParse
|
||||||
|
|
||||||
|
pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) { return nil, fail }
|
||||||
|
if _, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err == nil {
|
||||||
|
t.Fatal("git install failure should propagate")
|
||||||
|
}
|
||||||
|
pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) {
|
||||||
|
return &plugin.Plugin{Manifest: plugin.Manifest{Name: "git-plugin", Version: "1.0.0"}}, nil
|
||||||
|
}
|
||||||
|
if out, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err != nil || !strings.Contains(out, "git-plugin") {
|
||||||
|
t.Fatalf("git install = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if _, err := pluginCoverageRun(newPluginDisableCommand(), "missing"); err == nil {
|
||||||
|
t.Fatal("disable missing plugin should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidDir := filepath.Join(work, "invalid")
|
||||||
|
if err := os.MkdirAll(invalidDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(invalidDir, "plugin.json"), []byte(`{"name":"ok-name","version":"1.0.0","type":"invalid"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pluginCoverageRun(newPluginValidateCommand(), invalidDir); err == nil {
|
||||||
|
t.Fatal("invalid manifest validation should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
|
||||||
|
pluginMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
if _, err := pluginCoverageRun(newPluginCreateCommand(), "mkdir-plugin"); err == nil {
|
||||||
|
t.Fatal("scaffold mkdir failure should propagate")
|
||||||
|
}
|
||||||
|
pluginMkdirAll = oldMkdir
|
||||||
|
for _, target := range []string{"plugin.json", "SKILL.md", "hooks.json"} {
|
||||||
|
name := "write-" + strings.ToLower(strings.TrimSuffix(target, filepath.Ext(target)))
|
||||||
|
pluginWriteFile = func(path string, data []byte, mode os.FileMode) error {
|
||||||
|
if strings.HasSuffix(path, target) {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return oldWrite(path, data, mode)
|
||||||
|
}
|
||||||
|
if _, err := pluginCoverageRun(newPluginCreateCommand(), name); err == nil {
|
||||||
|
t.Fatalf("scaffold %s failure should propagate", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pluginWriteFile = oldWrite
|
||||||
|
|
||||||
|
pluginAbs = func(string) (string, error) { return "", fail }
|
||||||
|
if _, err := pluginCoverageRun(newPluginDevCommand(), "dir"); err == nil {
|
||||||
|
t.Fatal("dev absolute-path failure should propagate")
|
||||||
|
}
|
||||||
|
pluginAbs = oldAbs
|
||||||
|
if _, err := pluginCoverageRun(newPluginDevCommand(), invalidDir); err == nil {
|
||||||
|
t.Fatal("dev manifest validation should fail")
|
||||||
|
}
|
||||||
|
validDir := filepath.Join(work, "valid")
|
||||||
|
if err := os.MkdirAll(validDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifest := `{
|
||||||
|
"name":"valid-plugin","version":"1.0.0","type":"user",
|
||||||
|
"userConfig":{
|
||||||
|
"API_KEY":{"description":"secret key","sensitive":true},
|
||||||
|
"PLAIN":{"description":"plain value","default":"default"},
|
||||||
|
"UNSET":{"description":"required value"}
|
||||||
|
},
|
||||||
|
"build":{"command":"true","output":"bin/server"}
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pluginRegisterDev = func(*plugin.Loader, string, string) error { return fail }
|
||||||
|
if _, err := pluginCoverageRun(newPluginDevCommand(), validDir); err == nil {
|
||||||
|
t.Fatal("dev registration failure should propagate")
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := plugin.NewLoader(RawVersion())
|
||||||
|
installed, err := loader.InstallFromDir(validDir)
|
||||||
|
if err != nil {
|
||||||
|
// The declared build output is intentionally absent; install a no-build copy.
|
||||||
|
noBuild := strings.Replace(manifest, ",\n \"build\":{\"command\":\"true\",\"output\":\"bin/server\"}", "", 1)
|
||||||
|
if writeErr := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(noBuild), 0o600); writeErr != nil {
|
||||||
|
t.Fatal(writeErr)
|
||||||
|
}
|
||||||
|
installed, err = loader.InstallFromDir(validDir)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loader.SetPluginConfig("valid-plugin", "API_KEY", "abcdefghijk")
|
||||||
|
loader.SetPluginConfig("valid-plugin", "PLAIN", "value")
|
||||||
|
for _, args := range [][]string{{"valid-plugin"}, {"valid-plugin", "--json"}} {
|
||||||
|
out, runErr := pluginCoverageRun(newPluginConfigListCommand(), args...)
|
||||||
|
if runErr != nil || !strings.Contains(out, "UNSET") || !strings.Contains(out, "abcd") {
|
||||||
|
t.Fatalf("config list %#v = %q, %v", args, out, runErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(installed.Root, "plugin.json"), []byte("{"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := loadDeclaredUserConfig(loader, "valid-plugin"); got != nil {
|
||||||
|
t.Fatalf("corrupt declared config = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginAbs = func(string) (string, error) { return "", fail }
|
||||||
|
if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil {
|
||||||
|
t.Fatal("build absolute-path failure should propagate")
|
||||||
|
}
|
||||||
|
pluginAbs = oldAbs
|
||||||
|
if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pluginBuild = func(string) error { return fail }
|
||||||
|
if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil {
|
||||||
|
t.Fatal("plugin build failure should propagate")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,10 +66,10 @@ func newProfileListCommand() *cobra.Command {
|
|||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||||
}
|
}
|
||||||
cfg, err := authpkg.LoadProfiles(configDir)
|
cfg, err := profileLoadProfiles(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,12 @@ func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
|
|||||||
var (
|
var (
|
||||||
profileSwitchSelector = selectProfileSwitchProfile
|
profileSwitchSelector = selectProfileSwitchProfile
|
||||||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||||||
|
profileSwitchTUIRunner = runProfileSwitchTUI
|
||||||
|
profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||||||
|
profileLoadProfiles = authpkg.LoadProfiles
|
||||||
|
profileUsePrevious = authpkg.UsePreviousProfile
|
||||||
|
profileSetCurrent = authpkg.SetCurrentProfile
|
||||||
|
profileRunTeaProgram = (*tea.Program).Run
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -218,9 +224,9 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
|
|||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if strings.TrimSpace(selector) == "-" {
|
if strings.TrimSpace(selector) == "-" {
|
||||||
profile, err = authpkg.UsePreviousProfile(configDir)
|
profile, err = profileUsePrevious(configDir)
|
||||||
} else {
|
} else {
|
||||||
profile, err = authpkg.SetCurrentProfile(configDir, selector)
|
profile, err = profileSetCurrent(configDir, selector)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewValidation(err.Error())
|
return apperrors.NewValidation(err.Error())
|
||||||
@@ -229,7 +235,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT
|
|||||||
clearCompatCache()
|
clearCompatCache()
|
||||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||||
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
|
||||||
cfg, loadErr := authpkg.LoadProfiles(configDir)
|
cfg, loadErr := profileLoadProfiles(configDir)
|
||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
|
||||||
}
|
}
|
||||||
@@ -243,10 +249,10 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
|||||||
if !profileSwitchInteractiveTerminal() {
|
if !profileSwitchInteractiveTerminal() {
|
||||||
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
|
||||||
}
|
}
|
||||||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||||||
}
|
}
|
||||||
cfg, err := authpkg.LoadProfiles(configDir)
|
cfg, err := profileLoadProfiles(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||||||
}
|
}
|
||||||
@@ -260,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e
|
|||||||
if choice == "" {
|
if choice == "" {
|
||||||
choice = cfg.Profiles[0].CorpID
|
choice = cfg.Profiles[0].CorpID
|
||||||
}
|
}
|
||||||
return runProfileSwitchTUI(cmd, cfg, choice)
|
return profileSwitchTUIRunner(cmd, cfg, choice)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
|
||||||
@@ -272,7 +278,7 @@ func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, select
|
|||||||
tea.WithOutput(cmd.ErrOrStderr()),
|
tea.WithOutput(cmd.ErrOrStderr()),
|
||||||
tea.WithContext(cmd.Context()),
|
tea.WithContext(cmd.Context()),
|
||||||
)
|
)
|
||||||
finalModel, err := program.Run()
|
finalModel, err := profileRunTeaProgram(program)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, tea.ErrInterrupted) {
|
if errors.Is(err, tea.ErrInterrupted) {
|
||||||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileRemainingCoverage(t *testing.T) {
|
||||||
|
oldMigrate := profileEnsureProfilesMigration
|
||||||
|
oldLoad := profileLoadProfiles
|
||||||
|
oldPrevious := profileUsePrevious
|
||||||
|
oldCurrent := profileSetCurrent
|
||||||
|
oldInteractive := profileSwitchInteractiveTerminal
|
||||||
|
oldTUI := profileSwitchTUIRunner
|
||||||
|
oldProgram := profileRunTeaProgram
|
||||||
|
t.Cleanup(func() {
|
||||||
|
profileEnsureProfilesMigration = oldMigrate
|
||||||
|
profileLoadProfiles = oldLoad
|
||||||
|
profileUsePrevious = oldPrevious
|
||||||
|
profileSetCurrent = oldCurrent
|
||||||
|
profileSwitchInteractiveTerminal = oldInteractive
|
||||||
|
profileSwitchTUIRunner = oldTUI
|
||||||
|
profileRunTeaProgram = oldProgram
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
|
||||||
|
list := newProfileListCommand()
|
||||||
|
_, _, _ = authCoverageRoot(list, "table", false)
|
||||||
|
profileEnsureProfilesMigration = func(string) error { return fail }
|
||||||
|
if err := list.RunE(list, nil); err == nil {
|
||||||
|
t.Fatal("profile-list migration should fail")
|
||||||
|
}
|
||||||
|
profileEnsureProfilesMigration = func(string) error { return nil }
|
||||||
|
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
|
||||||
|
if err := list.RunE(list, nil); err == nil {
|
||||||
|
t.Fatal("profile-list load should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
selectorCmd := &cobra.Command{}
|
||||||
|
addProfileSwitchSelectorFlags(selectorCmd)
|
||||||
|
_ = selectorCmd.Flags().Set("name", " ")
|
||||||
|
if _, err := profileSwitchSelectorFromCommand(selectorCmd, nil); err == nil {
|
||||||
|
t.Fatal("blank profile selector should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
profileSetCurrent = func(string, string) (*authpkg.Profile, error) {
|
||||||
|
return &authpkg.Profile{CorpID: "ding", CorpName: "Corp"}, nil
|
||||||
|
}
|
||||||
|
profileUsePrevious = func(string) (*authpkg.Profile, error) { return nil, fail }
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
_, _, _ = authCoverageRoot(cmd, "table", false)
|
||||||
|
if err := switchProfileAndWrite(cmd, "cfg", "-", false); err == nil {
|
||||||
|
t.Fatal("previous-profile error should fail")
|
||||||
|
}
|
||||||
|
profileUsePrevious = func(string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "previous"}, nil }
|
||||||
|
if err := switchProfileAndWrite(cmd, "cfg", "-", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jsonCmd := &cobra.Command{}
|
||||||
|
_, _, _ = authCoverageRoot(jsonCmd, "json", false)
|
||||||
|
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
|
||||||
|
if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil {
|
||||||
|
t.Fatal("JSON profile load should fail")
|
||||||
|
}
|
||||||
|
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||||
|
return &authpkg.ProfilesConfig{CurrentProfile: "ding"}, nil
|
||||||
|
}
|
||||||
|
jsonCmd.SetOut(&appFailWriter{err: fail})
|
||||||
|
if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil {
|
||||||
|
t.Fatal("JSON profile write should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
profileSwitchInteractiveTerminal = func() bool { return true }
|
||||||
|
profileEnsureProfilesMigration = func(string) error { return fail }
|
||||||
|
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
|
||||||
|
t.Fatal("selector migration should fail")
|
||||||
|
}
|
||||||
|
profileEnsureProfilesMigration = func(string) error { return nil }
|
||||||
|
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail }
|
||||||
|
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
|
||||||
|
t.Fatal("selector load should fail")
|
||||||
|
}
|
||||||
|
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||||
|
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil {
|
||||||
|
t.Fatal("empty selector profiles should fail")
|
||||||
|
}
|
||||||
|
choices := []string{}
|
||||||
|
profileSwitchTUIRunner = func(_ *cobra.Command, _ *authpkg.ProfilesConfig, choice string) (string, error) {
|
||||||
|
choices = append(choices, choice)
|
||||||
|
return choice, nil
|
||||||
|
}
|
||||||
|
for _, cfg := range []*authpkg.ProfilesConfig{
|
||||||
|
{CurrentProfile: "current", PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}},
|
||||||
|
{PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}},
|
||||||
|
{Profiles: []authpkg.Profile{{CorpID: "first"}}},
|
||||||
|
} {
|
||||||
|
profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||||
|
if _, err := selectProfileSwitchProfile(cmd, "cfg"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(choices) != 3 || choices[0] != "current" || choices[1] != "primary" || choices[2] != "first" {
|
||||||
|
t.Fatalf("profile choices = %#v", choices)
|
||||||
|
}
|
||||||
|
|
||||||
|
tuiCmd := &cobra.Command{}
|
||||||
|
tuiCmd.SetContext(context.Background())
|
||||||
|
tuiCmd.SetIn(io.NopCloser(&emptyReader{}))
|
||||||
|
tuiCmd.SetErr(io.Discard)
|
||||||
|
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "ding"}}}
|
||||||
|
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, tea.ErrInterrupted }
|
||||||
|
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||||
|
t.Fatal("interrupted TUI should fail")
|
||||||
|
}
|
||||||
|
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, fail }
|
||||||
|
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||||
|
t.Fatal("failed TUI should fail")
|
||||||
|
}
|
||||||
|
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return structModel{}, nil }
|
||||||
|
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||||
|
t.Fatal("wrong TUI model should fail")
|
||||||
|
}
|
||||||
|
for _, model := range []profileSwitchTUIModel{{aborted: true}, {submitted: false}} {
|
||||||
|
model := model
|
||||||
|
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return model, nil }
|
||||||
|
if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil {
|
||||||
|
t.Fatal("aborted TUI should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final := newProfileSwitchTUIModel(cfg, "ding")
|
||||||
|
final.submitted = true
|
||||||
|
profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return final, nil }
|
||||||
|
if got, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err != nil || got != "ding" {
|
||||||
|
t.Fatalf("submitted TUI = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := make([]authpkg.Profile, 8)
|
||||||
|
model := profileSwitchTUIModel{profiles: profiles, selected: 7, offset: 99}
|
||||||
|
model.ensureSelectedVisible()
|
||||||
|
if model.offset != 3 {
|
||||||
|
t.Fatalf("clamped offset = %d", model.offset)
|
||||||
|
}
|
||||||
|
model.offset = -2
|
||||||
|
model.selected = 0
|
||||||
|
model.ensureSelectedVisible()
|
||||||
|
if model.offset != 0 {
|
||||||
|
t.Fatalf("negative offset = %d", model.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type emptyReader struct{}
|
||||||
|
|
||||||
|
func (*emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
|
||||||
|
|
||||||
|
type structModel struct{}
|
||||||
|
|
||||||
|
func (structModel) Init() tea.Cmd { return nil }
|
||||||
|
func (structModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return structModel{}, nil }
|
||||||
|
func (structModel) View() string { return "" }
|
||||||
@@ -18,6 +18,11 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
recoverySavePlan = (*recovery.Store).SavePlan
|
||||||
|
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||||
|
)
|
||||||
|
|
||||||
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
|
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
|
||||||
var (
|
var (
|
||||||
planUseLast bool
|
planUseLast bool
|
||||||
@@ -60,7 +65,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
|
|||||||
EnableDocSearch: true,
|
EnableDocSearch: true,
|
||||||
})
|
})
|
||||||
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||||
if err := store.SavePlan(last.EventID, plan); err != nil {
|
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
|
||||||
return fmt.Errorf("保存恢复计划失败: %w", err)
|
return fmt.Errorf("保存恢复计划失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +95,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
|
|||||||
planner := recovery.NewPlanner(runtime)
|
planner := recovery.NewPlanner(runtime)
|
||||||
executor := recovery.NewExecutor(planner, runtime)
|
executor := recovery.NewExecutor(planner, runtime)
|
||||||
bundle := executor.Execute(cmd.Context(), *last)
|
bundle := executor.Execute(cmd.Context(), *last)
|
||||||
if err := store.SaveAnalysis(last.EventID, bundle.Plan, bundle); err != nil {
|
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
|
||||||
return fmt.Errorf("保存恢复分析失败: %w", err)
|
return fmt.Errorf("保存恢复分析失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||||
|
cmd := newRecoveryCommand(context.Background(), cli.StaticLoader{}, &GlobalFlags{})
|
||||||
|
out := &strings.Builder{}
|
||||||
|
cmd.SetOut(out)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetArgs(cmdArgs)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||||
|
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
|
||||||
|
t.Cleanup(func() {
|
||||||
|
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
|
||||||
|
})
|
||||||
|
configDir := t.TempDir()
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||||
|
store := recovery.NewStore(configDir)
|
||||||
|
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
|
||||||
|
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||||
|
t.Fatal("injected plan save failure succeeded")
|
||||||
|
}
|
||||||
|
recoverySavePlan = oldSavePlan
|
||||||
|
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
|
||||||
|
return errors.New("save analysis")
|
||||||
|
}
|
||||||
|
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||||
|
t.Fatal("injected analysis save failure succeeded")
|
||||||
|
}
|
||||||
|
recoverySaveAnalysis = oldSaveAnalysis
|
||||||
|
|
||||||
|
parent := newRecoveryCommand(context.Background(), nil, nil)
|
||||||
|
parent.SetOut(io.Discard)
|
||||||
|
if err := parent.RunE(parent, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
|
||||||
|
t.Fatalf("recovery plan = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
|
||||||
|
t.Fatalf("recovery execute = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"finalize"},
|
||||||
|
{"finalize", "--event-id", last.EventID},
|
||||||
|
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
|
||||||
|
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
|
||||||
|
} {
|
||||||
|
if _, err := recoveryCoverageRun(args...); err == nil {
|
||||||
|
t.Fatalf("recovery finalize %#v should fail", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
executionPath := filepath.Join(t.TempDir(), "execution.json")
|
||||||
|
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
|
||||||
|
t.Fatalf("recovery finalize = %q, %v", out, err)
|
||||||
|
}
|
||||||
|
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
|
||||||
|
t.Fatal("conflicting snapshot selectors should fail")
|
||||||
|
}
|
||||||
|
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
|
||||||
|
t.Fatal("missing event snapshot should fail")
|
||||||
|
}
|
||||||
|
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
|
||||||
|
t.Fatal("empty snapshot selector should fail")
|
||||||
|
}
|
||||||
|
missingStore := recovery.NewStore(t.TempDir())
|
||||||
|
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
|
||||||
|
t.Fatal("missing latest snapshot should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
|
||||||
|
if err := os.Remove(eventsPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(eventsPath, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||||
|
t.Fatal("recovery plan save should fail")
|
||||||
|
}
|
||||||
|
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||||
|
t.Fatal("recovery analysis save should fail")
|
||||||
|
}
|
||||||
|
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
|
||||||
|
t.Fatal("recovery finalization save should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
|
||||||
|
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
|
||||||
|
path := filepath.Join(t.TempDir(), "execution.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := loadRecoveryExecution(path); err == nil {
|
||||||
|
t.Fatal("invalid attempts should fail")
|
||||||
|
}
|
||||||
|
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
|
||||||
|
t.Fatal("invalid attempt array should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
fail := errors.New("catalog")
|
||||||
|
SetDynamicServers(nil)
|
||||||
|
runtime := &recoveryRuntime{
|
||||||
|
loader: cli.CatalogLoaderFrom(cli.Catalog{}, fail),
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
}
|
||||||
|
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("direct resolution error = %v", err)
|
||||||
|
}
|
||||||
|
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||||
|
t.Fatalf("search error = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
|
||||||
|
t.Fatalf("empty doc search items = %#v", got)
|
||||||
|
}
|
||||||
|
for _, payload := range []map[string]any{
|
||||||
|
{"data": map[string]any{}},
|
||||||
|
{"result": map[string]any{}},
|
||||||
|
} {
|
||||||
|
if got := parseDocSearchItemsFromMap(payload); got != nil {
|
||||||
|
t.Fatalf("empty nested doc search items = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-22
@@ -49,6 +49,30 @@ type outputFileContextKey struct{}
|
|||||||
|
|
||||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||||
|
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||||
|
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||||
|
rootRunPreParse = pipeline.RunPreParse
|
||||||
|
rootLatestRecoveryCapture = recovery.LatestCapture
|
||||||
|
rootResetRecoveryState = recovery.ResetRuntimeState
|
||||||
|
rootStopAllStdioClients = StopAllStdioClients
|
||||||
|
rootLoadPlugins = loadPlugins
|
||||||
|
rootMkdirAll = os.MkdirAll
|
||||||
|
rootCreateFile = os.Create
|
||||||
|
rootCloseFile = (*os.File).Close
|
||||||
|
rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv
|
||||||
|
rootPluginLoadUser = (*plugin.Loader).LoadUser
|
||||||
|
rootPluginLoadDev = (*plugin.Loader).LoadDev
|
||||||
|
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
|
||||||
|
rootPluginStdioClients = (*plugin.Plugin).StdioClients
|
||||||
|
rootRegisterPluginHTTPServer = registerPluginHTTPServer
|
||||||
|
rootRegisterStdioManifest = registerStdioServerFromManifest
|
||||||
|
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
|
||||||
|
rootPluginSyncSkills = plugin.SyncSkills
|
||||||
|
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||||
|
)
|
||||||
|
|
||||||
// Execute runs the root command and returns the process exit code.
|
// Execute runs the root command and returns the process exit code.
|
||||||
func Execute() (exitCode int) {
|
func Execute() (exitCode int) {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -58,13 +82,13 @@ func Execute() (exitCode int) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
restoreArgs := normalizeProcessProfileArgs()
|
restoreArgs := rootNormalizeProcessProfileArgs()
|
||||||
defer restoreArgs()
|
defer restoreArgs()
|
||||||
|
|
||||||
timing := NewTimingCollector()
|
timing := NewTimingCollector()
|
||||||
defer func() {
|
defer func() {
|
||||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
rootStopAllStdioClients() // Ensure child processes are terminated on exit
|
||||||
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
CloseAuditSink() // Drain async audit forwards on all exit paths,
|
||||||
// including command errors where Cobra skips PersistentPostRunE.
|
// including command errors where Cobra skips PersistentPostRunE.
|
||||||
timing.PrintIfEnabled()
|
timing.PrintIfEnabled()
|
||||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||||
@@ -77,17 +101,17 @@ func Execute() (exitCode int) {
|
|||||||
ctx = WithTimingCollector(ctx, timing)
|
ctx = WithTimingCollector(ctx, timing)
|
||||||
|
|
||||||
initStart := time.Now()
|
initStart := time.Now()
|
||||||
recovery.ResetRuntimeState()
|
rootResetRecoveryState()
|
||||||
engine := newPipelineEngine()
|
engine := newPipelineEngine()
|
||||||
root := NewRootCommandWithEngine(ctx, engine)
|
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||||
timing.Record("cmd_init", time.Since(initStart))
|
timing.Record("cmd_init", time.Since(initStart))
|
||||||
|
|
||||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||||
// This corrects model-generated errors like --userId → --user-id
|
// This corrects model-generated errors like --userId → --user-id
|
||||||
// and --limit100 → --limit 100.
|
// and --limit100 → --limit 100.
|
||||||
pipeline.RunPreParse(root, engine)
|
rootRunPreParse(root, engine)
|
||||||
|
|
||||||
executed, err := root.ExecuteC()
|
executed, err := rootExecuteCommand(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if executed == nil {
|
if executed == nil {
|
||||||
executed = root
|
executed = root
|
||||||
@@ -99,7 +123,7 @@ func Execute() (exitCode int) {
|
|||||||
_, _ = fmt.Fprintln(os.Stderr)
|
_, _ = fmt.Fprintln(os.Stderr)
|
||||||
}
|
}
|
||||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||||
if last := recovery.LatestCapture(); last != nil && last.EventID != "" {
|
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||||||
}
|
}
|
||||||
return apperrors.ExitCode(err)
|
return apperrors.ExitCode(err)
|
||||||
@@ -374,7 +398,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
|||||||
|
|
||||||
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
|
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
|
||||||
// be appended on top of the static endpoint registry.
|
// be appended on top of the static endpoint registry.
|
||||||
pluginCmds := loadPlugins(engine, runner)
|
pluginCmds := rootLoadPlugins(engine, runner)
|
||||||
if len(pluginCmds) > 0 {
|
if len(pluginCmds) > 0 {
|
||||||
addPluginCommandsSafe(root, pluginCmds)
|
addPluginCommandsSafe(root, pluginCmds)
|
||||||
}
|
}
|
||||||
@@ -686,10 +710,10 @@ func configureOutputSink(cmd *cobra.Command) error {
|
|||||||
if err := validateOptionalPath("--output", outputPath); err != nil {
|
if err := validateOptionalPath("--output", outputPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
|
||||||
}
|
}
|
||||||
file, err := os.Create(outputPath)
|
file, err := rootCreateFile(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
|
||||||
}
|
}
|
||||||
@@ -703,7 +727,7 @@ func closeOutputSink(cmd *cobra.Command) error {
|
|||||||
if !ok || file == nil {
|
if !ok || file == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := file.Close(); err != nil {
|
if err := rootCloseFile(file); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -775,10 +799,10 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
|||||||
// variables so that expandPluginVars can resolve ${KEY} references
|
// variables so that expandPluginVars can resolve ${KEY} references
|
||||||
// in plugin.json headers, endpoints, etc. User-set env vars take
|
// in plugin.json headers, endpoints, etc. User-set env vars take
|
||||||
// precedence (InjectPluginConfigEnv skips already-set keys).
|
// precedence (InjectPluginConfigEnv skips already-set keys).
|
||||||
pluginLoader.InjectPluginConfigEnv()
|
rootPluginInjectConfigEnv(pluginLoader)
|
||||||
|
|
||||||
// Load TokenData once; reused for stdio injection below.
|
// Load TokenData once; reused for stdio injection below.
|
||||||
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
|
tokenData, _ := rootAuthLoadTokenData(defaultConfigDir())
|
||||||
var userCtx *plugin.UserContext
|
var userCtx *plugin.UserContext
|
||||||
if tokenData != nil {
|
if tokenData != nil {
|
||||||
// Inject user context if either UserID or CorpID is present.
|
// Inject user context if either UserID or CorpID is present.
|
||||||
@@ -791,32 +815,32 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Load user plugins (per settings.json)
|
// 1. Load user plugins (per settings.json)
|
||||||
userPlugins := pluginLoader.LoadUser()
|
userPlugins := rootPluginLoadUser(pluginLoader)
|
||||||
|
|
||||||
// 2. Load dev plugins (registered via `dws plugin dev`)
|
// 2. Load dev plugins (registered via `dws plugin dev`)
|
||||||
devPlugins := pluginLoader.LoadDev()
|
devPlugins := rootPluginLoadDev(pluginLoader)
|
||||||
|
|
||||||
allPlugins := append(userPlugins, devPlugins...)
|
allPlugins := append(userPlugins, devPlugins...)
|
||||||
|
|
||||||
// 3. Register HTTP descriptors and authentication from the manifest.
|
// 3. Register HTTP descriptors and authentication from the manifest.
|
||||||
for _, p := range allPlugins {
|
for _, p := range allPlugins {
|
||||||
for _, srv := range p.ToServerDescriptors() {
|
for _, srv := range rootPluginDescriptors(p) {
|
||||||
registerPluginHTTPServer(srv)
|
rootRegisterPluginHTTPServer(srv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Register stdio descriptors and unstarted clients. The subprocess is
|
// 4. Register stdio descriptors and unstarted clients. The subprocess is
|
||||||
// started and initialized only when a command is actually executed.
|
// started and initialized only when a command is actually executed.
|
||||||
for _, p := range allPlugins {
|
for _, p := range allPlugins {
|
||||||
for _, sc := range p.StdioClients(userCtx) {
|
for _, sc := range rootPluginStdioClients(p, userCtx) {
|
||||||
registerStdioServerFromManifest(p, sc)
|
rootRegisterStdioManifest(p, sc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Register plugin hooks into pipeline engine
|
// 5. Register plugin hooks into pipeline engine
|
||||||
if engine != nil {
|
if engine != nil {
|
||||||
for _, p := range allPlugins {
|
for _, p := range allPlugins {
|
||||||
hooksCfg, err := p.LoadHooks()
|
hooksCfg, err := rootPluginLoadHooks(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("plugin: failed to load hooks",
|
slog.Warn("plugin: failed to load hooks",
|
||||||
"plugin", p.Manifest.Name, "error", err)
|
"plugin", p.Manifest.Name, "error", err)
|
||||||
@@ -832,7 +856,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Sync plugin skills to agent directories
|
// 7. Sync plugin skills to agent directories
|
||||||
plugin.SyncSkills(allPlugins)
|
rootPluginSyncSkills(allPlugins)
|
||||||
|
|
||||||
if len(allPlugins) > 0 {
|
if len(allPlugins) > 0 {
|
||||||
slog.Debug("plugins loaded",
|
slog.Debug("plugins loaded",
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||||
|
oldNormalize := rootNormalizeProcessProfileArgs
|
||||||
|
oldExecute := rootExecuteCommand
|
||||||
|
oldNewRoot := rootNewRootCommandWithEngine
|
||||||
|
oldPreParse := rootRunPreParse
|
||||||
|
oldLatest := rootLatestRecoveryCapture
|
||||||
|
oldReset := rootResetRecoveryState
|
||||||
|
oldStop := rootStopAllStdioClients
|
||||||
|
oldArgs := os.Args
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootNormalizeProcessProfileArgs = oldNormalize
|
||||||
|
rootExecuteCommand = oldExecute
|
||||||
|
rootNewRootCommandWithEngine = oldNewRoot
|
||||||
|
rootRunPreParse = oldPreParse
|
||||||
|
rootLatestRecoveryCapture = oldLatest
|
||||||
|
rootResetRecoveryState = oldReset
|
||||||
|
rootStopAllStdioClients = oldStop
|
||||||
|
os.Args = oldArgs
|
||||||
|
})
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||||
|
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {}
|
||||||
|
rootResetRecoveryState = func() {}
|
||||||
|
rootStopAllStdioClients = func() {}
|
||||||
|
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||||
|
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||||
|
}
|
||||||
|
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
|
||||||
|
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||||
|
if code := Execute(); code != 0 {
|
||||||
|
t.Fatalf("successful Execute code = %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantErr := errors.New("unknown command missing")
|
||||||
|
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||||
|
if code := Execute(); code == 0 {
|
||||||
|
t.Fatal("failed Execute returned zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||||
|
if code := Execute(); code != 5 {
|
||||||
|
t.Fatalf("panic Execute code = %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootConstructionHooksAndVersionCoverage(t *testing.T) {
|
||||||
|
oldLoadPlugins := rootLoadPlugins
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootLoadPlugins = oldLoadPlugins
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
|
||||||
|
})
|
||||||
|
|
||||||
|
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||||
|
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
|
||||||
|
}
|
||||||
|
preRunCalled := false
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
AfterPersistentPreRun: func(*cobra.Command, []string) error { preRunCalled = true; return nil },
|
||||||
|
RegisterExtraCommands: func(root *cobra.Command, _ edition.ToolCaller) {
|
||||||
|
root.AddCommand(&cobra.Command{Use: "extra", Run: func(*cobra.Command, []string) {}})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
root := NewRootCommandWithEngine(context.Background(), pipeline.NewEngine())
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs([]string{"version", "--client-id", "client", "--client-secret", "secret", "--debug"})
|
||||||
|
if err := root.Execute(); err != nil || !preRunCalled {
|
||||||
|
t.Fatalf("root version execution = %v preRun=%v", err, preRunCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
root = NewRootCommandWithEngine(context.Background(), nil)
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs(nil)
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("root help execution = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
standalone := newVersionCommand()
|
||||||
|
standalone.Flags().String("format", "", "")
|
||||||
|
standalone.SetOut(io.Discard)
|
||||||
|
standalone.SetArgs([]string{"--format", "json"})
|
||||||
|
version, buildTime, gitCommit = "1.2.3", "today", "commit"
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if err := standalone.Execute(); err != nil {
|
||||||
|
t.Fatalf("JSON version = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root = NewRootCommandWithEngine(context.Background(), nil)
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs([]string{"version", "--output", "bad\x00path"})
|
||||||
|
if err := root.Execute(); err == nil {
|
||||||
|
t.Fatal("unsafe output path succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootFlagsPluginsAndOutputRemainingCoverage(t *testing.T) {
|
||||||
|
parent := &cobra.Command{Use: "root"}
|
||||||
|
parent.PersistentFlags().String("format", "json", "")
|
||||||
|
child := &cobra.Command{Use: "child"}
|
||||||
|
parent.AddCommand(child)
|
||||||
|
if !wantsJSONErrors(child) {
|
||||||
|
t.Fatal("root JSON format was not inherited")
|
||||||
|
}
|
||||||
|
localRoot := &cobra.Command{Use: "root"}
|
||||||
|
localRoot.Flags().String("format", "json", "")
|
||||||
|
localChild := &cobra.Command{Use: "child"}
|
||||||
|
localRoot.AddCommand(localChild)
|
||||||
|
if !wantsJSONErrors(localChild) {
|
||||||
|
t.Fatal("root-local JSON format was not recognized")
|
||||||
|
}
|
||||||
|
falseJSON := &cobra.Command{Use: "false-json"}
|
||||||
|
falseJSON.Flags().Bool("json", true, "")
|
||||||
|
_ = falseJSON.Flags().Set("json", "false")
|
||||||
|
if commandRequestsJSONErrors(falseJSON) {
|
||||||
|
t.Fatal("explicit false JSON flag requested JSON")
|
||||||
|
}
|
||||||
|
brokenJSON := &cobra.Command{Use: "broken"}
|
||||||
|
brokenJSON.Flags().String("json", "not-bool", "")
|
||||||
|
_ = brokenJSON.Flags().Set("json", "value")
|
||||||
|
if !commandRequestsJSONErrors(brokenJSON) {
|
||||||
|
t.Fatal("changed non-bool json flag was not treated as JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginRoot := &cobra.Command{Use: "root"}
|
||||||
|
pluginRoot.AddCommand(&cobra.Command{Use: "market"})
|
||||||
|
addPluginCommandsSafe(pluginRoot, []*cobra.Command{
|
||||||
|
{Use: "auth"},
|
||||||
|
{Use: "duplicate"},
|
||||||
|
{Use: "duplicate"},
|
||||||
|
{Use: "market"},
|
||||||
|
})
|
||||||
|
|
||||||
|
oldMkdir := rootMkdirAll
|
||||||
|
oldCreate := rootCreateFile
|
||||||
|
oldClose := rootCloseFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootMkdirAll = oldMkdir
|
||||||
|
rootCreateFile = oldCreate
|
||||||
|
rootCloseFile = oldClose
|
||||||
|
})
|
||||||
|
wantErr := errors.New("filesystem")
|
||||||
|
newOutputCommand := func(path string) *cobra.Command {
|
||||||
|
root := &cobra.Command{Use: "root"}
|
||||||
|
root.PersistentFlags().String("output", path, "")
|
||||||
|
cmd := &cobra.Command{Use: "output"}
|
||||||
|
root.AddCommand(cmd)
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
successPath := filepath.Join(t.TempDir(), "success", "out")
|
||||||
|
successCmd := newOutputCommand(successPath)
|
||||||
|
if err := configureOutputSink(successCmd); err != nil {
|
||||||
|
t.Fatalf("output sink success = %v", err)
|
||||||
|
}
|
||||||
|
if err := closeOutputSink(successCmd); err != nil {
|
||||||
|
t.Fatalf("output sink close = %v", err)
|
||||||
|
}
|
||||||
|
badTypeRoot := &cobra.Command{Use: "root"}
|
||||||
|
badTypeRoot.PersistentFlags().Bool("output", false, "")
|
||||||
|
badTypeChild := &cobra.Command{Use: "child"}
|
||||||
|
badTypeRoot.AddCommand(badTypeChild)
|
||||||
|
if err := configureOutputSink(badTypeChild); err == nil {
|
||||||
|
t.Fatal("non-string output flag succeeded")
|
||||||
|
}
|
||||||
|
rootMkdirAll = func(string, os.FileMode) error { return wantErr }
|
||||||
|
if err := configureOutputSink(newOutputCommand(filepath.Join(t.TempDir(), "dir", "out"))); err == nil {
|
||||||
|
t.Fatal("mkdir failure succeeded")
|
||||||
|
}
|
||||||
|
rootMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
rootCreateFile = func(string) (*os.File, error) { return nil, wantErr }
|
||||||
|
if err := configureOutputSink(newOutputCommand(filepath.Join(t.TempDir(), "out"))); err == nil {
|
||||||
|
t.Fatal("create failure succeeded")
|
||||||
|
}
|
||||||
|
rootCreateFile = oldCreate
|
||||||
|
file, err := os.CreateTemp(t.TempDir(), "close")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := &cobra.Command{Use: "close"}
|
||||||
|
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||||
|
rootCloseFile = func(*os.File) error { return wantErr }
|
||||||
|
if err := closeOutputSink(cmd); err == nil {
|
||||||
|
t.Fatal("close failure succeeded")
|
||||||
|
}
|
||||||
|
rootCloseFile = oldClose
|
||||||
|
file, err = os.CreateTemp(t.TempDir(), "close-success")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file))
|
||||||
|
if err := closeOutputSink(cmd); err != nil {
|
||||||
|
t.Fatalf("close success = %v", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
for _, flags := range []*GlobalFlags{nil, {Debug: true}, {Verbose: true}, {}} {
|
||||||
|
configureLogLevel(flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootLoadPluginsRemainingCoverage(t *testing.T) {
|
||||||
|
oldInject := rootPluginInjectConfigEnv
|
||||||
|
oldUser := rootPluginLoadUser
|
||||||
|
oldDev := rootPluginLoadDev
|
||||||
|
oldDescriptors := rootPluginDescriptors
|
||||||
|
oldStdioClients := rootPluginStdioClients
|
||||||
|
oldHTTP := rootRegisterPluginHTTPServer
|
||||||
|
oldStdio := rootRegisterStdioManifest
|
||||||
|
oldHooks := rootPluginLoadHooks
|
||||||
|
oldSync := rootPluginSyncSkills
|
||||||
|
oldToken := rootAuthLoadTokenData
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootPluginInjectConfigEnv = oldInject
|
||||||
|
rootPluginLoadUser = oldUser
|
||||||
|
rootPluginLoadDev = oldDev
|
||||||
|
rootPluginDescriptors = oldDescriptors
|
||||||
|
rootPluginStdioClients = oldStdioClients
|
||||||
|
rootRegisterPluginHTTPServer = oldHTTP
|
||||||
|
rootRegisterStdioManifest = oldStdio
|
||||||
|
rootPluginLoadHooks = oldHooks
|
||||||
|
rootPluginSyncSkills = oldSync
|
||||||
|
rootAuthLoadTokenData = oldToken
|
||||||
|
})
|
||||||
|
|
||||||
|
p1 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "one"}}
|
||||||
|
p2 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "two"}}
|
||||||
|
p3 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "three"}}
|
||||||
|
rootPluginInjectConfigEnv = func(*plugin.Loader) {}
|
||||||
|
rootPluginLoadUser = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p1, p2} }
|
||||||
|
rootPluginLoadDev = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p3} }
|
||||||
|
rootAuthLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{UserID: "user", CorpID: "corp"}, nil
|
||||||
|
}
|
||||||
|
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
|
||||||
|
if p == p1 {
|
||||||
|
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
|
||||||
|
}
|
||||||
|
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
|
||||||
|
}
|
||||||
|
client := transport.NewStdioClient("ignored", nil, nil)
|
||||||
|
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
|
||||||
|
if p == p1 && uc != nil && uc.UserID == "user" {
|
||||||
|
return []plugin.StdioServerClient{{Key: "local", Client: client}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
httpCount := 0
|
||||||
|
stdioCount := 0
|
||||||
|
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
|
||||||
|
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
|
||||||
|
stdioCount++
|
||||||
|
return mcptypes.ServerDescriptor{}
|
||||||
|
}
|
||||||
|
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
|
||||||
|
switch p {
|
||||||
|
case p1:
|
||||||
|
return nil, errors.New("hooks")
|
||||||
|
case p2:
|
||||||
|
return nil, nil
|
||||||
|
default:
|
||||||
|
return &plugin.HooksConfig{Hooks: []plugin.HookEntry{{Phase: "pre-request", Command: "true"}}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synced := false
|
||||||
|
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
|
||||||
|
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
|
||||||
|
t.Fatalf("loaded plugin commands = %#v", got)
|
||||||
|
}
|
||||||
|
if httpCount != 3 || stdioCount != 1 || !synced {
|
||||||
|
t.Fatalf("registered http=%d stdio=%d synced=%v", httpCount, stdioCount, synced)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,9 +183,6 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allowed := resolveVisibleProducts()
|
allowed := resolveVisibleProducts()
|
||||||
if len(allowed) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
commands := make([]*cobra.Command, 0)
|
commands := make([]*cobra.Command, 0)
|
||||||
for _, cmd := range root.Commands() {
|
for _, cmd := range root.Commands() {
|
||||||
|
|||||||
+37
-26
@@ -20,6 +20,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -40,6 +41,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
runnerHandlePatAuthCheck = handlePatAuthCheck
|
||||||
|
runnerRetryWithPatAuthRetry = retryWithPatAuthRetry
|
||||||
|
|
||||||
configmeta.Register(configmeta.ConfigItem{
|
configmeta.Register(configmeta.ConfigItem{
|
||||||
Name: "DWS_RUNTIME_CONTENT_SCAN",
|
Name: "DWS_RUNTIME_CONTENT_SCAN",
|
||||||
Category: configmeta.CategoryRuntime,
|
Category: configmeta.CategoryRuntime,
|
||||||
@@ -158,6 +162,19 @@ type runtimeRunner struct {
|
|||||||
auditSink audit.Sink
|
auditSink audit.Sink
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||||
|
runnerResolveProfile = authpkg.ResolveProfile
|
||||||
|
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||||
|
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||||
|
runnerCallTool = (*transport.Client).CallTool
|
||||||
|
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||||
|
runnerStdioCallTool = (*transport.StdioClient).CallTool
|
||||||
|
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
|
||||||
|
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
|
||||||
|
runnerCaptureRuntimeFailure = captureRuntimeFailure
|
||||||
|
)
|
||||||
|
|
||||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||||
// Global dry-run is an execution barrier, not merely a transport option.
|
// Global dry-run is an execution barrier, not merely a transport option.
|
||||||
// Return a deterministic local preview before profile resolution, catalog
|
// Return a deterministic local preview before profile resolution, catalog
|
||||||
@@ -177,7 +194,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
|||||||
// invocations within the same process free.
|
// invocations within the same process free.
|
||||||
logHostOwnedPATDecisionOnce()
|
logHostOwnedPATDecisionOnce()
|
||||||
|
|
||||||
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||||
}
|
}
|
||||||
@@ -207,7 +224,7 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
|||||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||||
// resolution and catalog loading below.
|
// resolution and catalog loading below.
|
||||||
if prefetchToken {
|
if prefetchToken {
|
||||||
go getCachedRuntimeToken(ctx)
|
go runnerGetCachedRuntimeToken(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldUseDirectRuntime(invocation) {
|
if shouldUseDirectRuntime(invocation) {
|
||||||
@@ -278,7 +295,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
|||||||
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
if p, err := runnerResolveProfile(configDir, rawSelector); err == nil && p != nil {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +307,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
|||||||
if selector == "" {
|
if selector == "" {
|
||||||
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
|
||||||
}
|
}
|
||||||
profile, err := authpkg.ResolveProfile(configDir, selector)
|
profile, err := runnerResolveProfile(configDir, selector)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -306,9 +323,6 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil
|
|||||||
Profile: *profile,
|
Profile: *profile,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if len(selections) == 0 {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
return selections, true, nil
|
return selections, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,25 +584,25 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
|
if err := runnerPreflightDocDownload(r, callCtx, tc, endpoint, invocation); err != nil {
|
||||||
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
||||||
if IsPatRetrying(ctx) {
|
if IsPatRetrying(ctx) {
|
||||||
return executor.Result{}, patCheck
|
return executor.Result{}, patCheck
|
||||||
}
|
}
|
||||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
captureRuntimeFailure(invocation, err, err)
|
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||||
return executor.Result{}, err
|
return executor.Result{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
callStart := time.Now()
|
callStart := time.Now()
|
||||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isAuthError(err) {
|
if isAuthError(err) {
|
||||||
if fn := edition.Get().OnAuthError; fn != nil {
|
if fn := edition.Get().OnAuthError; fn != nil {
|
||||||
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
||||||
captureRuntimeFailure(invocation, err, overrideErr)
|
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
|
||||||
return executor.Result{}, overrideErr
|
return executor.Result{}, overrideErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,10 +610,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
// PAT scope error: offer human-readable output and retry after authorization
|
// PAT scope error: offer human-readable output and retry after authorization
|
||||||
if isPatScopeError(err) {
|
if isPatScopeError(err) {
|
||||||
scopeErr := extractPatScopeError(err)
|
scopeErr := extractPatScopeError(err)
|
||||||
captureRuntimeFailure(invocation, err, err)
|
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
captureRuntimeFailure(invocation, err, err)
|
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||||
return executor.Result{}, err
|
return executor.Result{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +624,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
if IsPatRetrying(ctx) {
|
if IsPatRetrying(ctx) {
|
||||||
return executor.Result{}, patCheck // already retried once, don't loop
|
return executor.Result{}, patCheck // already retried once, don't loop
|
||||||
}
|
}
|
||||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
return executor.Result{}, editionErr
|
return executor.Result{}, editionErr
|
||||||
}
|
}
|
||||||
@@ -621,7 +635,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
if IsPatRetrying(ctx) {
|
if IsPatRetrying(ctx) {
|
||||||
return executor.Result{}, patCheck // already retried once, don't loop
|
return executor.Result{}, patCheck // already retried once, don't loop
|
||||||
}
|
}
|
||||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if callResult.IsError {
|
if callResult.IsError {
|
||||||
@@ -632,7 +646,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||||
captureRuntimeFailure(invocation, hookErr, hookErr)
|
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
|
||||||
return executor.Result{}, hookErr
|
return executor.Result{}, hookErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,10 +662,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
// PAT scope error in business response: offer human-readable output and retry
|
// PAT scope error in business response: offer human-readable output and retry
|
||||||
if isPatScopeError(mcpErr) {
|
if isPatScopeError(mcpErr) {
|
||||||
scopeErr := extractPatScopeError(mcpErr)
|
scopeErr := extractPatScopeError(mcpErr)
|
||||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||||
return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
captureRuntimeFailure(invocation, mcpErr, mcpErr)
|
runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr)
|
||||||
return executor.Result{}, mcpErr
|
return executor.Result{}, mcpErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +734,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
|||||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
}
|
}
|
||||||
if err := client.EnsureInitialized(callCtx); err != nil {
|
if err := runnerStdioEnsureInitialized(client, callCtx); err != nil {
|
||||||
return executor.Result{}, apperrors.NewAPI(
|
return executor.Result{}, apperrors.NewAPI(
|
||||||
fmt.Sprintf("stdio initialize failed: %v", err),
|
fmt.Sprintf("stdio initialize failed: %v", err),
|
||||||
apperrors.WithOperation("initialize"),
|
apperrors.WithOperation("initialize"),
|
||||||
@@ -728,7 +742,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params)
|
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return executor.Result{}, apperrors.NewAPI(
|
return executor.Result{}, apperrors.NewAPI(
|
||||||
fmt.Sprintf("stdio call failed: %v", err),
|
fmt.Sprintf("stdio call failed: %v", err),
|
||||||
@@ -889,9 +903,6 @@ func productEndpointOverride(productID string) (string, bool) {
|
|||||||
func resolveIdentityHeaders() map[string]string {
|
func resolveIdentityHeaders() map[string]string {
|
||||||
id := authpkg.EnsureExists(defaultConfigDir())
|
id := authpkg.EnsureExists(defaultConfigDir())
|
||||||
headers := id.Headers()
|
headers := id.Headers()
|
||||||
if headers == nil {
|
|
||||||
headers = make(map[string]string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject environment variable based headers for MCP gateway tracking.
|
// Inject environment variable based headers for MCP gateway tracking.
|
||||||
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
// DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runnerCoverageFallback struct {
|
||||||
|
result executor.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f runnerCoverageFallback) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||||
|
return f.result, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||||
|
oldResolveMulti := runnerResolveMultiProfileSelections
|
||||||
|
oldResolveProfile := runnerResolveProfile
|
||||||
|
oldCachedToken := runnerGetCachedRuntimeToken
|
||||||
|
t.Cleanup(func() {
|
||||||
|
runnerResolveMultiProfileSelections = oldResolveMulti
|
||||||
|
runnerResolveProfile = oldResolveProfile
|
||||||
|
runnerGetCachedRuntimeToken = oldCachedToken
|
||||||
|
})
|
||||||
|
|
||||||
|
created := newCommandRunnerWithFlags(cli.StaticLoader{}, &GlobalFlags{Timeout: 2})
|
||||||
|
if created.(*runtimeRunner).transport == nil {
|
||||||
|
t.Fatal("runner transport was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantErr := errors.New("profiles failed")
|
||||||
|
runnerResolveMultiProfileSelections = func(string, string) ([]multiProfileSelection, bool, error) {
|
||||||
|
return nil, false, wantErr
|
||||||
|
}
|
||||||
|
if _, err := (&runtimeRunner{}).Run(context.Background(), executor.Invocation{}); err == nil {
|
||||||
|
t.Fatal("profile resolution error was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
|
||||||
|
prefetched := make(chan struct{}, 1)
|
||||||
|
runnerGetCachedRuntimeToken = func(context.Context) string {
|
||||||
|
prefetched <- struct{}{}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := &runtimeRunner{
|
||||||
|
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
fallback: runnerCoverageFallback{},
|
||||||
|
}
|
||||||
|
directMiss := inv
|
||||||
|
directMiss.Kind = "helper_invocation"
|
||||||
|
if _, err := r.runSingle(context.Background(), directMiss, false); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("direct runtime miss load error = %v", err)
|
||||||
|
}
|
||||||
|
directHit := executor.Invocation{Kind: "helper_invocation", CanonicalProduct: defaultPATProductID, Tool: "pat", DryRun: true}
|
||||||
|
if got, err := r.runSingle(context.Background(), directHit, false); err != nil || got.Response["dry_run"] != true {
|
||||||
|
t.Fatalf("direct runtime hit = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := r.runSingle(context.Background(), inv, true); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("runSingle error = %v", err)
|
||||||
|
}
|
||||||
|
<-prefetched
|
||||||
|
|
||||||
|
runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||||
|
if selector == "a,b" {
|
||||||
|
return nil, errors.New("not a combined profile")
|
||||||
|
}
|
||||||
|
if selector == "a" {
|
||||||
|
return nil, wantErr
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, _, err := resolveMultiProfileSelections("", "a,b"); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("profile error = %v", err)
|
||||||
|
}
|
||||||
|
runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||||
|
if selector == "a,b" {
|
||||||
|
return nil, errors.New("not a combined profile")
|
||||||
|
}
|
||||||
|
if selector == "a" {
|
||||||
|
return &authpkg.Profile{CorpID: "corp-a"}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, _, err := resolveMultiProfileSelections("", "a,b"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Fatalf("nil profile error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
selections := []multiProfileSelection{
|
||||||
|
{Selector: "bad", Profile: authpkg.Profile{CorpID: "bad"}},
|
||||||
|
{Selector: "good", Profile: authpkg.Profile{CorpID: "good"}},
|
||||||
|
}
|
||||||
|
r = &runtimeRunner{fallback: runnerCoverageFallback{err: wantErr}}
|
||||||
|
result, err := r.runMultiProfile(context.Background(), inv, selections[:1])
|
||||||
|
if err != nil || result.Response == nil {
|
||||||
|
t.Fatalf("multi failure aggregation = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
r.fallback = runnerCoverageFallback{result: executor.Result{Response: map[string]any{"endpoint": "local", "value": 1}}}
|
||||||
|
result, err = r.runMultiProfile(context.Background(), inv, selections[1:])
|
||||||
|
if err != nil || result.Response == nil {
|
||||||
|
t.Fatalf("multi success aggregation = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||||
|
r = &runtimeRunner{
|
||||||
|
loader: cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}},
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
globalFlags: &GlobalFlags{DryRun: true},
|
||||||
|
fallback: runnerCoverageFallback{},
|
||||||
|
}
|
||||||
|
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test")
|
||||||
|
got, err := r.runSingle(context.Background(), inv, false)
|
||||||
|
if err != nil || got.Response["endpoint"] != "https://override.test" {
|
||||||
|
t.Fatalf("catalog override = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldPreflight := runnerPreflightDocDownload
|
||||||
|
oldCall := runnerCallTool
|
||||||
|
oldHandle := runnerHandlePatAuthCheck
|
||||||
|
oldRetry := runnerRetryWithPatAuthRetry
|
||||||
|
oldCapture := runnerCaptureRuntimeFailure
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
runnerPreflightDocDownload = oldPreflight
|
||||||
|
runnerCallTool = oldCall
|
||||||
|
runnerHandlePatAuthCheck = oldHandle
|
||||||
|
runnerRetryWithPatAuthRetry = oldRetry
|
||||||
|
runnerCaptureRuntimeFailure = oldCapture
|
||||||
|
})
|
||||||
|
|
||||||
|
pluginAuthMu.Lock()
|
||||||
|
oldPluginRegistry := pluginAuthRegistry
|
||||||
|
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||||
|
pluginAuthMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pluginAuthMu.Lock()
|
||||||
|
pluginAuthRegistry = oldPluginRegistry
|
||||||
|
pluginAuthMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
|
||||||
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||||
|
}
|
||||||
|
r := &runtimeRunner{
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
globalFlags: &GlobalFlags{Token: "token", Timeout: 1},
|
||||||
|
}
|
||||||
|
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool", Params: map[string]any{"x": 1}}
|
||||||
|
|
||||||
|
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
|
||||||
|
t.Fatalf("default auth execution = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantErr := errors.New("preflight")
|
||||||
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
return wantErr
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("preflight error = %v", err)
|
||||||
|
}
|
||||||
|
patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
|
||||||
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
return patErr
|
||||||
|
}
|
||||||
|
retrying := context.WithValue(context.Background(), patRetryingKey, true)
|
||||||
|
if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) {
|
||||||
|
t.Fatalf("retrying preflight PAT = %v", err)
|
||||||
|
}
|
||||||
|
handled := errors.New("handled PAT")
|
||||||
|
runnerHandlePatAuthCheck = func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) {
|
||||||
|
return executor.Result{}, handled
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
|
||||||
|
t.Fatalf("handled preflight PAT = %v", err)
|
||||||
|
}
|
||||||
|
runnerPreflightDocDownload = oldPreflight
|
||||||
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authErr := apperrors.NewAuth("expired")
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{}, authErr
|
||||||
|
}
|
||||||
|
overrideErr := errors.New("auth override")
|
||||||
|
edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return overrideErr }})
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, overrideErr) {
|
||||||
|
t.Fatalf("auth override = %v", err)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return nil }})
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, authErr) {
|
||||||
|
t.Fatalf("auth passthrough = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeErr := errors.New("missing_scope calendar:read")
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{}, scopeErr
|
||||||
|
}
|
||||||
|
retried := errors.New("scope retried")
|
||||||
|
runnerRetryWithPatAuthRetry = func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) {
|
||||||
|
return executor.Result{}, retried
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) {
|
||||||
|
t.Fatalf("scope retry = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{}, wantErr
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("generic call error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patContent := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f"}}
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{Content: patContent}, nil
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return patErr }})
|
||||||
|
if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) {
|
||||||
|
t.Fatalf("edition retry PAT = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
|
||||||
|
t.Fatalf("edition handled PAT = %v", err)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return wantErr }})
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("edition classification = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
if _, err := r.executeInvocation(retrying, "https://example.test", inv); err == nil {
|
||||||
|
t.Fatal("built-in retrying PAT succeeded")
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) {
|
||||||
|
t.Fatalf("built-in handled PAT = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
|
||||||
|
callCount++
|
||||||
|
if callCount%2 == 0 {
|
||||||
|
return wantErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}})
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "business"}}, nil
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("business hook = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Text: "missing_scope mail:read"}}, Content: map[string]any{}}, nil
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) {
|
||||||
|
t.Fatalf("business scope retry = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.scanner = coverageScanner{report: safety.Report{Scanned: true, Findings: []safety.Finding{{Pattern: "bad"}}}}
|
||||||
|
r.enforceContentScan = true
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||||
|
}
|
||||||
|
if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); err == nil {
|
||||||
|
t.Fatal("scan failure succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
|
||||||
|
oldStdioInit := runnerStdioEnsureInitialized
|
||||||
|
oldStdioCall := runnerStdioCallTool
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
runnerStdioEnsureInitialized = oldStdioInit
|
||||||
|
runnerStdioCallTool = oldStdioCall
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
StopAllStdioClients()
|
||||||
|
})
|
||||||
|
|
||||||
|
client := transport.NewStdioClient("unused", nil, nil)
|
||||||
|
RegisterStdioClient("stdio-product", client)
|
||||||
|
r := &runtimeRunner{globalFlags: &GlobalFlags{Timeout: 1}}
|
||||||
|
inv := executor.Invocation{CanonicalProduct: "stdio-product", Tool: "tool"}
|
||||||
|
wantErr := errors.New("stdio failed")
|
||||||
|
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return wantErr }
|
||||||
|
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
|
||||||
|
t.Fatalf("stdio initialize error = %v", err)
|
||||||
|
}
|
||||||
|
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
||||||
|
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{}, wantErr
|
||||||
|
}
|
||||||
|
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
|
||||||
|
t.Fatalf("stdio call error = %v", err)
|
||||||
|
}
|
||||||
|
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "tool failed"}}, nil
|
||||||
|
}
|
||||||
|
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
|
||||||
|
t.Fatalf("stdio tool error = %v", err)
|
||||||
|
}
|
||||||
|
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
|
||||||
|
}
|
||||||
|
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
|
||||||
|
t.Fatalf("stdio success = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.globalFlags.Token = " explicit "
|
||||||
|
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
|
||||||
|
t.Fatalf("explicit auth token = %q", got)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
||||||
|
_, _ = fallback()
|
||||||
|
return "provided", nil
|
||||||
|
}})
|
||||||
|
r.globalFlags.Token = ""
|
||||||
|
if got := r.resolveAuthToken(context.Background()); got != "provided" {
|
||||||
|
t.Fatalf("provided auth token = %q", got)
|
||||||
|
}
|
||||||
|
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
|
||||||
|
t.Fatalf("runtime explicit token = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(envDWSChannel, "channel")
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
MergeHeaders: func(headers map[string]string) map[string]string { return headers },
|
||||||
|
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||||
|
headers["x-enterprise"] = "yes"
|
||||||
|
return headers
|
||||||
|
},
|
||||||
|
})
|
||||||
|
headers := resolveIdentityHeaders()
|
||||||
|
if headers["x-dws-channel"] != "channel" || headers["x-enterprise"] != "yes" {
|
||||||
|
t.Fatalf("identity headers = %#v", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := detectBusinessError(map[string]any{"content": map[string]any{"success": false, "errorMsg": "nested"}}); got != "nested" {
|
||||||
|
t.Fatalf("nested business error = %q", got)
|
||||||
|
}
|
||||||
|
if got := detectBusinessError(map[string]any{"success": false, "data": map[string]any{"success": false, "errorMsg": "nested-first"}}); got != "nested-first" {
|
||||||
|
t.Fatalf("nested-first business error = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,28 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
skillLoadAccessToken = loadSkillAccessToken
|
||||||
|
skillDownloadToTmp = downloadSkillToTmpDir
|
||||||
|
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
||||||
|
skillNewRequest = http.NewRequestWithContext
|
||||||
|
skillLoadTokenData = authpkg.LoadTokenData
|
||||||
|
skillResolveTargetPath = resolveSkillTargetPath
|
||||||
|
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
||||||
|
skillDownloadFile = downloadSkillFile
|
||||||
|
skillExtractZip = extractSkillZip
|
||||||
|
skillUserHomeDir = os.UserHomeDir
|
||||||
|
skillMkdirTemp = os.MkdirTemp
|
||||||
|
skillCreate = os.Create
|
||||||
|
skillCreateTemp = os.CreateTemp
|
||||||
|
skillRemoveAll = os.RemoveAll
|
||||||
|
skillRemove = os.Remove
|
||||||
|
skillMkdirAll = os.MkdirAll
|
||||||
|
skillOpenFile = os.OpenFile
|
||||||
|
skillCopy = io.Copy
|
||||||
|
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
configmeta.Register(configmeta.ConfigItem{
|
configmeta.Register(configmeta.ConfigItem{
|
||||||
Name: "DWS_SKILL_API_HOST",
|
Name: "DWS_SKILL_API_HOST",
|
||||||
@@ -274,7 +296,7 @@ func newSkillAddHintCommand() *cobra.Command {
|
|||||||
|
|
||||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||||
accessToken, err := loadSkillAccessToken()
|
accessToken, err := skillLoadAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -282,7 +304,7 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
|
|||||||
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
|
apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID)))
|
||||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...")
|
||||||
|
|
||||||
tmpDir, err := downloadSkillToTmpDir(cmd.Context(), apiURL, accessToken)
|
tmpDir, err := skillDownloadToTmp(cmd.Context(), apiURL, accessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -297,7 +319,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
source, _ = cmd.Flags().GetString("scopes")
|
source, _ = cmd.Flags().GetString("scopes")
|
||||||
}
|
}
|
||||||
accessToken, err := loadSkillAccessToken()
|
accessToken, err := skillLoadAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -306,14 +328,14 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
|
|||||||
if source != "" {
|
if source != "" {
|
||||||
apiURL += "&source=" + url.QueryEscape(source)
|
apiURL += "&source=" + url.QueryEscape(source)
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
req, err := skillNewRequest(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||||
}
|
}
|
||||||
req.Header.Set("x-user-access-token", accessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := skillHTTPDo(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
|
return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true))
|
||||||
}
|
}
|
||||||
@@ -361,12 +383,12 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve target path
|
// Resolve target path
|
||||||
destPath, err := resolveSkillTargetPath(target)
|
destPath, err := skillResolveTargetPath(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := loadSkillAccessToken()
|
accessToken, err := skillLoadAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -378,7 +400,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Step 1: Get download URL from API
|
// Step 1: Get download URL from API
|
||||||
fmt.Fprintf(w, "正在获取技能信息...\n")
|
fmt.Fprintf(w, "正在获取技能信息...\n")
|
||||||
downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID)
|
downloadResp, err := skillFetchDownloadInfo(ctx, accessToken, skillID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -401,7 +423,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Step 2: Download the skill zip file
|
// Step 2: Download the skill zip file
|
||||||
fmt.Fprintf(w, "正在下载技能...\n")
|
fmt.Fprintf(w, "正在下载技能...\n")
|
||||||
tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
|
tempZipPath, err := skillDownloadFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -409,7 +431,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Step 3: Extract zip to destination
|
// Step 3: Extract zip to destination
|
||||||
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
|
fmt.Fprintf(w, "正在解压到 %s...\n", destPath)
|
||||||
if err := extractSkillZip(tempZipPath, destPath); err != nil {
|
if err := skillExtractZip(tempZipPath, destPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +443,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
func loadSkillAccessToken() (string, error) {
|
func loadSkillAccessToken() (string, error) {
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
tokenData, err := authpkg.LoadTokenData(configDir)
|
tokenData, err := skillLoadTokenData(configDir)
|
||||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
||||||
return "", skillAuthError()
|
return "", skillAuthError()
|
||||||
}
|
}
|
||||||
@@ -464,7 +486,7 @@ func resolveSkillTargetPath(target string) (string, error) {
|
|||||||
return "", fmt.Errorf("unsupported target")
|
return "", fmt.Errorf("unsupported target")
|
||||||
}
|
}
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := skillUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -476,7 +498,7 @@ func resolveSkillTargetPath(target string) (string, error) {
|
|||||||
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
|
func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) {
|
||||||
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
|
url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := skillNewRequest(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||||
}
|
}
|
||||||
@@ -485,7 +507,7 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
|
|||||||
req.Header.Set("x-user-access-token", accessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := skillHTTPDo(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
|
return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err),
|
||||||
apperrors.WithRetryable(true))
|
apperrors.WithRetryable(true))
|
||||||
@@ -515,14 +537,14 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
|
func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
req, err := skillNewRequest(ctx, http.MethodGet, apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||||
}
|
}
|
||||||
req.Header.Set("x-user-access-token", accessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
|
|
||||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||||
resp, err := client.Do(req)
|
resp, err := skillHTTPDo(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
|
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true))
|
||||||
}
|
}
|
||||||
@@ -532,22 +554,22 @@ func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (str
|
|||||||
return "", parseLegacySkillAPIError(resp)
|
return "", parseLegacySkillAPIError(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpDir, err := os.MkdirTemp("", "dws-skill-*")
|
tmpDir, err := skillMkdirTemp("", "dws-skill-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
filename := filenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||||||
destPath := filepath.Join(tmpDir, filename)
|
destPath := filepath.Join(tmpDir, filename)
|
||||||
file, err := os.Create(destPath)
|
file, err := skillCreate(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(tmpDir)
|
_ = skillRemoveAll(tmpDir)
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
if _, err := skillCopy(file, resp.Body); err != nil {
|
||||||
os.RemoveAll(tmpDir)
|
_ = skillRemoveAll(tmpDir)
|
||||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||||
}
|
}
|
||||||
return tmpDir, nil
|
return tmpDir, nil
|
||||||
@@ -586,7 +608,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: skillDownloadTimeout}
|
client := &http.Client{Timeout: skillDownloadTimeout}
|
||||||
resp, err := client.Do(req)
|
resp, err := skillHTTPDo(client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
|
return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err),
|
||||||
apperrors.WithRetryable(true))
|
apperrors.WithRetryable(true))
|
||||||
@@ -602,21 +624,21 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
|
|||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
fileName = "skill.zip"
|
fileName = "skill.zip"
|
||||||
}
|
}
|
||||||
tempFile, err := os.CreateTemp("", "dws-skill-*.zip")
|
tempFile, err := skillCreateTemp("", "dws-skill-*.zip")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err))
|
||||||
}
|
}
|
||||||
tempPath := tempFile.Name()
|
tempPath := tempFile.Name()
|
||||||
|
|
||||||
// Copy response body to temp file
|
// Copy response body to temp file
|
||||||
_, err = io.Copy(tempFile, resp.Body)
|
_, err = skillCopy(tempFile, resp.Body)
|
||||||
closeErr := tempFile.Close()
|
closeErr := tempFile.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(tempPath)
|
_ = skillRemove(tempPath)
|
||||||
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err))
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
os.Remove(tempPath)
|
_ = skillRemove(tempPath)
|
||||||
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
|
return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +648,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin
|
|||||||
// extractSkillZip extracts a zip file to the destination directory.
|
// extractSkillZip extracts a zip file to the destination directory.
|
||||||
func extractSkillZip(zipPath, destDir string) error {
|
func extractSkillZip(zipPath, destDir string) error {
|
||||||
// Ensure destination directory exists
|
// Ensure destination directory exists
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := skillMkdirAll(destDir, 0755); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,16 +677,16 @@ func extractZipFile(file *zip.File, destDir string) error {
|
|||||||
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
// Use 0755 to ensure we have write permission for creating files inside
|
// Use 0755 to ensure we have write permission for creating files inside
|
||||||
return os.MkdirAll(filePath, 0755)
|
return skillMkdirAll(filePath, 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure parent directory exists with write permission
|
// Ensure parent directory exists with write permission
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
if err := skillMkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract file
|
// Extract file
|
||||||
srcFile, err := file.Open()
|
srcFile, err := skillOpenZipFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err))
|
||||||
}
|
}
|
||||||
@@ -675,13 +697,13 @@ func extractZipFile(file *zip.File, destDir string) error {
|
|||||||
if fileMode&0600 == 0 {
|
if fileMode&0600 == 0 {
|
||||||
fileMode = 0644
|
fileMode = 0644
|
||||||
}
|
}
|
||||||
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
destFile, err := skillOpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err))
|
||||||
}
|
}
|
||||||
defer destFile.Close()
|
defer destFile.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
if _, err := skillCopy(destFile, srcFile); err != nil {
|
||||||
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
|
return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +713,6 @@ func extractZipFile(file *zip.File, destDir string) error {
|
|||||||
// cleanupTempFile removes a temporary file, ignoring errors.
|
// cleanupTempFile removes a temporary file, ignoring errors.
|
||||||
func cleanupTempFile(path string) {
|
func cleanupTempFile(path string) {
|
||||||
if path != "" {
|
if path != "" {
|
||||||
os.Remove(path)
|
_ = skillRemove(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type skillErrorReadCloser struct{ err error }
|
||||||
|
|
||||||
|
func (r skillErrorReadCloser) Read([]byte) (int, error) { return 0, r.err }
|
||||||
|
func (skillErrorReadCloser) Close() error { return nil }
|
||||||
|
|
||||||
|
func skillCoverageResponse(status int, body string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillCoverageCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.Flags().String("skill-id", "id", "")
|
||||||
|
cmd.Flags().String("query", "query", "")
|
||||||
|
cmd.Flags().String("source", "", "")
|
||||||
|
cmd.Flags().String("scopes", "scope", "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillCommandHighLevelRemainingCoverage(t *testing.T) {
|
||||||
|
oldToken := skillLoadAccessToken
|
||||||
|
oldTmp := skillDownloadToTmp
|
||||||
|
oldHTTP := skillHTTPDo
|
||||||
|
oldNewRequest := skillNewRequest
|
||||||
|
oldTarget := skillResolveTargetPath
|
||||||
|
oldFetch := skillFetchDownloadInfo
|
||||||
|
oldDownload := skillDownloadFile
|
||||||
|
oldExtract := skillExtractZip
|
||||||
|
t.Cleanup(func() {
|
||||||
|
skillLoadAccessToken = oldToken
|
||||||
|
skillDownloadToTmp = oldTmp
|
||||||
|
skillHTTPDo = oldHTTP
|
||||||
|
skillNewRequest = oldNewRequest
|
||||||
|
skillResolveTargetPath = oldTarget
|
||||||
|
skillFetchDownloadInfo = oldFetch
|
||||||
|
skillDownloadFile = oldDownload
|
||||||
|
skillExtractZip = oldExtract
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
cmd := skillCoverageCommand()
|
||||||
|
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||||
|
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill get auth error = %v", err)
|
||||||
|
}
|
||||||
|
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||||
|
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||||
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
|
t.Fatal("skill find request failure should propagate")
|
||||||
|
}
|
||||||
|
skillNewRequest = oldNewRequest
|
||||||
|
skillDownloadToTmp = func(context.Context, string, string) (string, error) { return "", fail }
|
||||||
|
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill get download error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||||
|
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill find auth error = %v", err)
|
||||||
|
}
|
||||||
|
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||||
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
|
t.Fatal("skill find network failure should propagate")
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusBadRequest, "bad"), nil
|
||||||
|
}
|
||||||
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
|
t.Fatal("skill find HTTP failure should propagate")
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusOK, "{"), nil
|
||||||
|
}
|
||||||
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
|
t.Fatal("skill find malformed response should fail")
|
||||||
|
}
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"success":false,"errorMsg":"message"}`,
|
||||||
|
`{"success":false,"errorCode":"code"}`,
|
||||||
|
`{"success":false}`,
|
||||||
|
} {
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusOK, body), nil
|
||||||
|
}
|
||||||
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
|
t.Fatalf("skill find API failure %s should propagate", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skillResolveTargetPath = func(string) (string, error) { return "", fail }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
|
||||||
|
t.Fatal("invalid skill target should fail")
|
||||||
|
}
|
||||||
|
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
||||||
|
skillLoadAccessToken = func() (string, error) { return "", fail }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill add auth error = %v", err)
|
||||||
|
}
|
||||||
|
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
||||||
|
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill info error = %v", err)
|
||||||
|
}
|
||||||
|
for _, response := range []*downloadSkillResponse{
|
||||||
|
{Success: false, ErrorMsg: "message"},
|
||||||
|
{Success: false, ErrorCode: "code"},
|
||||||
|
{Success: false},
|
||||||
|
{Success: true},
|
||||||
|
{Success: true, Result: &downloadSkillResult{}},
|
||||||
|
} {
|
||||||
|
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return response, nil }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
|
||||||
|
t.Fatalf("invalid download response %#v should fail", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) {
|
||||||
|
return &downloadSkillResponse{Success: true, Result: &downloadSkillResult{DownloadURL: "url", FileName: "skill.zip"}}, nil
|
||||||
|
}
|
||||||
|
skillDownloadFile = func(context.Context, string, string) (string, error) { return "", fail }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill file download error = %v", err)
|
||||||
|
}
|
||||||
|
skillDownloadFile = func(context.Context, string, string) (string, error) { return "temp.zip", nil }
|
||||||
|
skillExtractZip = func(string, string) error { return fail }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("skill extraction error = %v", err)
|
||||||
|
}
|
||||||
|
skillExtractZip = func(string, string) error { return nil }
|
||||||
|
if err := runSkillAdd(cmd, []string{"id", "target"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillCommandLowLevelRemainingCoverage(t *testing.T) {
|
||||||
|
oldHTTP := skillHTTPDo
|
||||||
|
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
|
||||||
|
oldHome := skillUserHomeDir
|
||||||
|
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
|
||||||
|
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
|
||||||
|
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
skillHTTPDo = oldHTTP
|
||||||
|
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
|
||||||
|
skillUserHomeDir = oldHome
|
||||||
|
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
|
||||||
|
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
|
||||||
|
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
||||||
|
if _, err := loadSkillAccessToken(); err == nil {
|
||||||
|
t.Fatal("invalid skill access token succeeded")
|
||||||
|
}
|
||||||
|
skillLoadTokenData = oldLoadToken
|
||||||
|
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||||
|
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||||
|
t.Fatal("download-info request failure should propagate")
|
||||||
|
}
|
||||||
|
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "token"); err == nil {
|
||||||
|
t.Fatal("skill-file request failure should propagate")
|
||||||
|
}
|
||||||
|
skillNewRequest = oldNewRequest
|
||||||
|
skillUserHomeDir = func() (string, error) { return "", fail }
|
||||||
|
if _, err := resolveSkillTargetPath("codex"); err == nil {
|
||||||
|
t.Fatal("skill target HOME error should fail")
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_SKILL_API_HOST", "https://skill.test/")
|
||||||
|
if skillAPIHost() != "https://skill.test" {
|
||||||
|
t.Fatal("skill API override was not normalized")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||||
|
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||||
|
t.Fatal("download-info network failure should propagate")
|
||||||
|
}
|
||||||
|
for _, status := range []int{http.StatusUnauthorized, http.StatusBadGateway} {
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(status, "body"), nil
|
||||||
|
}
|
||||||
|
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||||
|
t.Fatalf("download-info HTTP %d should fail", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: skillErrorReadCloser{err: fail}}, nil
|
||||||
|
}
|
||||||
|
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||||
|
t.Fatal("download-info body failure should propagate")
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusOK, "{"), nil
|
||||||
|
}
|
||||||
|
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||||
|
t.Fatal("malformed download-info should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusOK, "body"), nil
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||||
|
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||||
|
t.Fatal("temporary skill download network failure should propagate")
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusOK, "body"), nil
|
||||||
|
}
|
||||||
|
skillMkdirTemp = func(string, string) (string, error) { return "", fail }
|
||||||
|
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||||
|
t.Fatal("download temp-dir failure should propagate")
|
||||||
|
}
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
skillMkdirTemp = func(string, string) (string, error) { return tmpDir, nil }
|
||||||
|
skillCreate = func(string) (*os.File, error) { return nil, fail }
|
||||||
|
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||||
|
t.Fatal("download temp-file failure should propagate")
|
||||||
|
}
|
||||||
|
skillCreate = func(string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") }
|
||||||
|
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||||
|
if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil {
|
||||||
|
t.Fatal("download save failure should propagate")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||||
|
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||||
|
t.Fatal("skill-file network failure should propagate")
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusBadGateway, "body"), nil
|
||||||
|
}
|
||||||
|
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||||
|
t.Fatal("skill-file HTTP failure should propagate")
|
||||||
|
}
|
||||||
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) {
|
||||||
|
return skillCoverageResponse(http.StatusOK, "body"), nil
|
||||||
|
}
|
||||||
|
skillCreateTemp = func(string, string) (*os.File, error) { return nil, fail }
|
||||||
|
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||||
|
t.Fatal("skill-file temp failure should propagate")
|
||||||
|
}
|
||||||
|
skillCreateTemp = func(string, string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") }
|
||||||
|
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||||
|
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||||
|
t.Fatal("skill-file copy failure should propagate")
|
||||||
|
}
|
||||||
|
skillCopy = func(w io.Writer, _ io.Reader) (int64, error) {
|
||||||
|
_ = w.(*os.File).Close()
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil {
|
||||||
|
t.Fatal("skill-file close failure should propagate")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
if err := extractSkillZip("missing", "dest"); err == nil {
|
||||||
|
t.Fatal("zip destination mkdir failure should propagate")
|
||||||
|
}
|
||||||
|
zipPath := filepath.Join(t.TempDir(), "files.zip")
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
_, _ = zw.Create("dir/")
|
||||||
|
w, _ := zw.Create("file")
|
||||||
|
_, _ = w.Write([]byte("content"))
|
||||||
|
header := &zip.FileHeader{Name: "mode-file", Method: zip.Store}
|
||||||
|
header.SetMode(0o111)
|
||||||
|
w, _ = zw.CreateHeader(header)
|
||||||
|
_, _ = w.Write([]byte("mode"))
|
||||||
|
_ = zw.Close()
|
||||||
|
if err := os.WriteFile(zipPath, buf.Bytes(), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
skillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
if err := extractZipFile(zr.File[0], t.TempDir()); err == nil {
|
||||||
|
t.Fatal("zip directory mkdir failure should propagate")
|
||||||
|
}
|
||||||
|
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||||
|
t.Fatal("zip parent mkdir failure should propagate")
|
||||||
|
}
|
||||||
|
skillMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
skillOpenZipFile = func(*zip.File) (io.ReadCloser, error) { return nil, fail }
|
||||||
|
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||||
|
t.Fatal("zip source-open failure should propagate")
|
||||||
|
}
|
||||||
|
skillOpenZipFile = oldZipOpen
|
||||||
|
skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
|
||||||
|
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||||
|
t.Fatal("zip destination-open failure should propagate")
|
||||||
|
}
|
||||||
|
skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "out") }
|
||||||
|
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||||
|
if err := extractZipFile(zr.File[1], t.TempDir()); err == nil {
|
||||||
|
t.Fatal("zip copy failure should propagate")
|
||||||
|
}
|
||||||
|
skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, nil }
|
||||||
|
var usedMode os.FileMode
|
||||||
|
skillOpenFile = func(_ string, _ int, mode os.FileMode) (*os.File, error) {
|
||||||
|
usedMode = mode
|
||||||
|
return os.CreateTemp(t.TempDir(), "mode")
|
||||||
|
}
|
||||||
|
if err := extractZipFile(zr.File[2], t.TempDir()); err != nil || usedMode != 0o644 {
|
||||||
|
t.Fatalf("zip fallback mode = %v, %v", usedMode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-42
@@ -41,6 +41,33 @@ const (
|
|||||||
skillSetupModeMulti = "multi"
|
skillSetupModeMulti = "multi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
skillSetupResolveMode = resolveSkillSetupMode
|
||||||
|
skillSetupResolveSource = resolveSkillSetupSourceOrEmbedded
|
||||||
|
skillSetupResolveTargets = resolveSkillSetupTargets
|
||||||
|
skillSetupListMulti = listMultiSkillNames
|
||||||
|
skillSetupFilterMulti = filterMultiSkillNames
|
||||||
|
skillSetupConfirm = confirmSkillSetup
|
||||||
|
skillSetupInstallMono = installSkillToHomes
|
||||||
|
skillSetupInstallMulti = installMultiSkillToHomes
|
||||||
|
skillSetupCopyDir = copyDir
|
||||||
|
skillSetupRunForm = (*huh.Form).Run
|
||||||
|
skillSetupInteractive = isInteractiveTerminal
|
||||||
|
skillSetupReadDir = os.ReadDir
|
||||||
|
skillSetupStat = os.Stat
|
||||||
|
skillSetupExecutable = os.Executable
|
||||||
|
skillSetupGetwd = os.Getwd
|
||||||
|
skillSetupUserHomeDir = os.UserHomeDir
|
||||||
|
skillSetupRemoveAll = os.RemoveAll
|
||||||
|
skillSetupMkdirAll = os.MkdirAll
|
||||||
|
skillSetupWalk = filepath.Walk
|
||||||
|
skillSetupRel = filepath.Rel
|
||||||
|
skillSetupReadlink = os.Readlink
|
||||||
|
skillSetupOpen = os.Open
|
||||||
|
skillSetupOpenFile = os.OpenFile
|
||||||
|
skillSetupCopy = io.Copy
|
||||||
|
)
|
||||||
|
|
||||||
func newSkillSetupCommand() *cobra.Command {
|
func newSkillSetupCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "setup",
|
Use: "setup",
|
||||||
@@ -89,7 +116,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
out := cmd.OutOrStdout()
|
out := cmd.OutOrStdout()
|
||||||
errOut := cmd.ErrOrStderr()
|
errOut := cmd.ErrOrStderr()
|
||||||
|
|
||||||
mode, err := resolveSkillSetupMode(mode, autoYes, out)
|
mode, err := skillSetupResolveMode(mode, autoYes, out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -98,13 +125,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
|
||||||
}
|
}
|
||||||
|
|
||||||
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
|
skillSrc, srcCleanup, err := skillSetupResolveSource(source, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer srcCleanup()
|
defer srcCleanup()
|
||||||
|
|
||||||
dests, err := resolveSkillSetupTargets(target, mode)
|
dests, err := skillSetupResolveTargets(target, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -112,14 +139,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||||||
var multiSkillNames []string
|
var multiSkillNames []string
|
||||||
if mode == skillSetupModeMulti {
|
if mode == skillSetupModeMulti {
|
||||||
allMultiSkillNames, listErr := listMultiSkillNames(skillSrc)
|
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
return listErr
|
return listErr
|
||||||
}
|
}
|
||||||
if len(allMultiSkillNames) == 0 {
|
if len(allMultiSkillNames) == 0 {
|
||||||
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
|
return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
|
||||||
}
|
}
|
||||||
filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw)
|
filtered, filterErr := skillSetupFilterMulti(allMultiSkillNames, includeRaw, excludeRaw)
|
||||||
if filterErr != nil {
|
if filterErr != nil {
|
||||||
return filterErr
|
return filterErr
|
||||||
}
|
}
|
||||||
@@ -142,7 +169,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !autoYes {
|
if !autoYes {
|
||||||
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
|
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -157,9 +184,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
|||||||
var installed, skipped int
|
var installed, skipped int
|
||||||
switch mode {
|
switch mode {
|
||||||
case skillSetupModeMono:
|
case skillSetupModeMono:
|
||||||
installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut)
|
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||||
case skillSetupModeMulti:
|
case skillSetupModeMulti:
|
||||||
installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut)
|
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||||
}
|
}
|
||||||
@@ -297,7 +324,7 @@ func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
|||||||
// listMultiSkillNames returns sorted names of subdirectories under src that
|
// listMultiSkillNames returns sorted names of subdirectories under src that
|
||||||
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
|
// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
|
||||||
func listMultiSkillNames(src string) ([]string, error) {
|
func listMultiSkillNames(src string) ([]string, error) {
|
||||||
entries, err := os.ReadDir(src)
|
entries, err := skillSetupReadDir(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
|
return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
|
||||||
}
|
}
|
||||||
@@ -306,7 +333,7 @@ func listMultiSkillNames(src string) ([]string, error) {
|
|||||||
if !e.IsDir() {
|
if !e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
|
if _, err := skillSetupStat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
|
||||||
names = append(names, e.Name())
|
names = append(names, e.Name())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +355,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
|||||||
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
|
return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if autoYes || !isInteractiveTerminal() {
|
if autoYes || !skillSetupInteractive() {
|
||||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||||||
return skillSetupModeMono, nil
|
return skillSetupModeMono, nil
|
||||||
}
|
}
|
||||||
@@ -346,7 +373,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
|||||||
Value(&choice),
|
Value(&choice),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err := form.Run(); err != nil {
|
if err := skillSetupRunForm(form); err != nil {
|
||||||
return "", fmt.Errorf("交互式选择中止: %w", err)
|
return "", fmt.Errorf("交互式选择中止: %w", err)
|
||||||
}
|
}
|
||||||
return choice, nil
|
return choice, nil
|
||||||
@@ -405,7 +432,7 @@ func skillSourceCandidates(explicit, subdir string) []string {
|
|||||||
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
|
||||||
roots = append(roots, env, filepath.Join(env, "skills", subdir))
|
roots = append(roots, env, filepath.Join(env, "skills", subdir))
|
||||||
}
|
}
|
||||||
if exe, err := os.Executable(); err == nil {
|
if exe, err := skillSetupExecutable(); err == nil {
|
||||||
exeDir := filepath.Dir(exe)
|
exeDir := filepath.Dir(exe)
|
||||||
roots = append(roots,
|
roots = append(roots,
|
||||||
filepath.Join(exeDir, "skills", subdir),
|
filepath.Join(exeDir, "skills", subdir),
|
||||||
@@ -413,13 +440,13 @@ func skillSourceCandidates(explicit, subdir string) []string {
|
|||||||
filepath.Join(exeDir, "..", "share", "skills", "dws"),
|
filepath.Join(exeDir, "..", "share", "skills", "dws"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if wd, err := os.Getwd(); err == nil {
|
if wd, err := skillSetupGetwd(); err == nil {
|
||||||
roots = append(roots, filepath.Join(wd, "skills", subdir))
|
roots = append(roots, filepath.Join(wd, "skills", subdir))
|
||||||
}
|
}
|
||||||
// User-level cache populated by install.sh / install.ps1 / npm install.js
|
// User-level cache populated by install.sh / install.ps1 / npm install.js
|
||||||
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
|
// from the dws-skills.zip release asset. Lets `dws skill setup` find a
|
||||||
// source even when the user has no source checkout on disk.
|
// source even when the user has no source checkout on disk.
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
if home, err := skillSetupUserHomeDir(); err == nil {
|
||||||
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
|
roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
|
||||||
}
|
}
|
||||||
return roots
|
return roots
|
||||||
@@ -431,16 +458,16 @@ func isSkillSourceRoot(path, mode string) bool {
|
|||||||
}
|
}
|
||||||
switch mode {
|
switch mode {
|
||||||
case skillSetupModeMono:
|
case skillSetupModeMono:
|
||||||
fi, err := os.Stat(filepath.Join(path, "SKILL.md"))
|
fi, err := skillSetupStat(filepath.Join(path, "SKILL.md"))
|
||||||
return err == nil && !fi.IsDir()
|
return err == nil && !fi.IsDir()
|
||||||
case skillSetupModeMulti:
|
case skillSetupModeMulti:
|
||||||
entries, err := os.ReadDir(path)
|
entries, err := skillSetupReadDir(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
|
if _, err := skillSetupStat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,7 +485,7 @@ func isSkillSourceRoot(path, mode string) bool {
|
|||||||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||||||
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
|
// - multi → <agent-home> (安装时把 src 下每个子目录拷成兄弟 skill)
|
||||||
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := skillSetupUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
|
return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
|
||||||
}
|
}
|
||||||
@@ -489,15 +516,12 @@ func detectExistingAgentHomes(home, mode string) []string {
|
|||||||
base := filepath.Join(home, rel)
|
base := filepath.Join(home, rel)
|
||||||
parent := filepath.Dir(base)
|
parent := filepath.Dir(base)
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
|
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, agentHomeForMode(base, mode))
|
out = append(out, agentHomeForMode(base, mode))
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
|
||||||
out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode))
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +554,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isInteractiveTerminal() {
|
if !skillSetupInteractive() {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +568,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
|||||||
Value(&confirm),
|
Value(&confirm),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err := form.Run(); err != nil {
|
if err := skillSetupRunForm(form); err != nil {
|
||||||
return false, fmt.Errorf("确认中止: %w", err)
|
return false, fmt.Errorf("确认中止: %w", err)
|
||||||
}
|
}
|
||||||
return confirm, nil
|
return confirm, nil
|
||||||
@@ -561,7 +585,7 @@ func mutualExclusionVictims(dest, mode string) []string {
|
|||||||
case skillSetupModeMono:
|
case skillSetupModeMono:
|
||||||
// dest = <agent-home>/dws → agent-home = parent
|
// dest = <agent-home>/dws → agent-home = parent
|
||||||
agentHome := filepath.Dir(dest)
|
agentHome := filepath.Dir(dest)
|
||||||
entries, err := os.ReadDir(agentHome)
|
entries, err := skillSetupReadDir(agentHome)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -576,7 +600,7 @@ func mutualExclusionVictims(dest, mode string) []string {
|
|||||||
case skillSetupModeMulti:
|
case skillSetupModeMulti:
|
||||||
// dest = <agent-home> → mono 残留是 dest/dws
|
// dest = <agent-home> → mono 残留是 dest/dws
|
||||||
monoPath := filepath.Join(dest, "dws")
|
monoPath := filepath.Join(dest, "dws")
|
||||||
if _, err := os.Stat(monoPath); err == nil {
|
if _, err := skillSetupStat(monoPath); err == nil {
|
||||||
return []string{monoPath}
|
return []string{monoPath}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -588,7 +612,7 @@ func mutualExclusionVictims(dest, mode string) []string {
|
|||||||
// Failures emit a warning to errOut but never abort the install.
|
// Failures emit a warning to errOut but never abort the install.
|
||||||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||||||
if err := os.RemoveAll(victim); err != nil {
|
if err := skillSetupRemoveAll(victim); err != nil {
|
||||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -602,17 +626,17 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
|||||||
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
|
// 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
|
||||||
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
|
cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
|
||||||
|
|
||||||
if err := os.RemoveAll(dest); err != nil {
|
if err := skillSetupRemoveAll(dest); err != nil {
|
||||||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
|
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := skillSetupMkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
|
fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := copyDir(src, dest); err != nil {
|
if err := skillSetupCopyDir(src, dest); err != nil {
|
||||||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
|
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
@@ -632,7 +656,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
|||||||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||||||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||||||
|
|
||||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
if err := skillSetupMkdirAll(dest, 0o755); err != nil {
|
||||||
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
|
fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
|
||||||
skipped += len(skillNames)
|
skipped += len(skillNames)
|
||||||
continue
|
continue
|
||||||
@@ -641,12 +665,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
|||||||
for _, name := range skillNames {
|
for _, name := range skillNames {
|
||||||
subSrc := filepath.Join(src, name)
|
subSrc := filepath.Join(src, name)
|
||||||
subDest := filepath.Join(dest, name)
|
subDest := filepath.Join(dest, name)
|
||||||
if err := os.RemoveAll(subDest); err != nil {
|
if err := skillSetupRemoveAll(subDest); err != nil {
|
||||||
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
|
fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := copyDir(subSrc, subDest); err != nil {
|
if err := skillSetupCopyDir(subSrc, subDest); err != nil {
|
||||||
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
|
fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
@@ -659,22 +683,22 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyDir(src, dst string) error {
|
func copyDir(src, dst string) error {
|
||||||
return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
|
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(src, path)
|
rel, err := skillSetupRel(src, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target := filepath.Join(dst, rel)
|
target := filepath.Join(dst, rel)
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
return os.MkdirAll(target, info.Mode())
|
return skillSetupMkdirAll(target, info.Mode())
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
// resolve symlink target and copy the underlying file
|
// resolve symlink target and copy the underlying file
|
||||||
resolved, err := os.Readlink(path)
|
resolved, err := skillSetupReadlink(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -688,22 +712,22 @@ func copyDir(src, dst string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func copyFileContent(src, dst string, mode os.FileMode) error {
|
func copyFileContent(src, dst string, mode os.FileMode) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
if err := skillSetupMkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
in, err := os.Open(src)
|
in, err := skillSetupOpen(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer in.Close()
|
defer in.Close()
|
||||||
|
|
||||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
|
out, err := skillSetupOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
_, err = io.Copy(out, in)
|
_, err = skillSetupCopy(out, in)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ import (
|
|||||||
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
embeddedSkillStat = func(name string) (fs.FileInfo, error) {
|
||||||
|
return fs.Stat(dwsroot.EmbeddedSkills, name)
|
||||||
|
}
|
||||||
|
embeddedSkillMkdirTemp = os.MkdirTemp
|
||||||
|
embeddedSkillRemoveAll = os.RemoveAll
|
||||||
|
embeddedSkillWalkDir = func(root string, fn fs.WalkDirFunc) error {
|
||||||
|
return fs.WalkDir(dwsroot.EmbeddedSkills, root, fn)
|
||||||
|
}
|
||||||
|
embeddedSkillReadFile = dwsroot.EmbeddedSkills.ReadFile
|
||||||
|
embeddedSkillMkdirAll = os.MkdirAll
|
||||||
|
embeddedSkillWriteFile = os.WriteFile
|
||||||
|
)
|
||||||
|
|
||||||
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
|
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
|
||||||
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
|
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
|
||||||
// override (validated as an on-disk dir). Otherwise it falls back to the skill
|
// override (validated as an on-disk dir). Otherwise it falls back to the skill
|
||||||
@@ -50,33 +64,33 @@ func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), e
|
|||||||
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
|
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
|
||||||
noop := func() {}
|
noop := func() {}
|
||||||
sub := "skills/" + mode // embed.FS always uses forward slashes
|
sub := "skills/" + mode // embed.FS always uses forward slashes
|
||||||
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
|
if _, err := embeddedSkillStat(sub); err != nil {
|
||||||
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
|
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
|
tmp, err := embeddedSkillMkdirTemp("", "dws-skill-"+mode+"-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
|
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
|
||||||
}
|
}
|
||||||
cleanup := func() { _ = os.RemoveAll(tmp) }
|
cleanup := func() { _ = embeddedSkillRemoveAll(tmp) }
|
||||||
|
|
||||||
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
|
walkErr := embeddedSkillWalkDir(sub, func(p string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
|
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
|
||||||
dst := filepath.Join(tmp, filepath.FromSlash(rel))
|
dst := filepath.Join(tmp, filepath.FromSlash(rel))
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
return os.MkdirAll(dst, 0o755)
|
return embeddedSkillMkdirAll(dst, 0o755)
|
||||||
}
|
}
|
||||||
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
|
data, readErr := embeddedSkillReadFile(p)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return readErr
|
return readErr
|
||||||
}
|
}
|
||||||
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
|
if mkErr := embeddedSkillMkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
|
||||||
return mkErr
|
return mkErr
|
||||||
}
|
}
|
||||||
return os.WriteFile(dst, data, 0o644)
|
return embeddedSkillWriteFile(dst, data, 0o644)
|
||||||
})
|
})
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type skillSetupFileInfo struct {
|
||||||
|
name string
|
||||||
|
mode os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i skillSetupFileInfo) Name() string { return i.name }
|
||||||
|
func (i skillSetupFileInfo) Size() int64 { return 0 }
|
||||||
|
func (i skillSetupFileInfo) Mode() os.FileMode { return i.mode }
|
||||||
|
func (i skillSetupFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (i skillSetupFileInfo) IsDir() bool { return i.mode.IsDir() }
|
||||||
|
func (i skillSetupFileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
|
func skillSetupCoverageCommand(t *testing.T, mode string, yes bool) *cobra.Command {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newSkillSetupCommand()
|
||||||
|
root := &cobra.Command{Use: "dws"}
|
||||||
|
root.PersistentFlags().Bool("dry-run", false, "")
|
||||||
|
root.AddCommand(cmd)
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
_ = cmd.Flags().Set("mode", mode)
|
||||||
|
_ = cmd.Flags().Set("yes", map[bool]string{true: "true", false: "false"}[yes])
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillSetupHighLevelRemainingCoverage(t *testing.T) {
|
||||||
|
oldMode := skillSetupResolveMode
|
||||||
|
oldSource := skillSetupResolveSource
|
||||||
|
oldTargets := skillSetupResolveTargets
|
||||||
|
oldList := skillSetupListMulti
|
||||||
|
oldFilter := skillSetupFilterMulti
|
||||||
|
oldConfirm := skillSetupConfirm
|
||||||
|
oldMono := skillSetupInstallMono
|
||||||
|
oldMulti := skillSetupInstallMulti
|
||||||
|
t.Cleanup(func() {
|
||||||
|
skillSetupResolveMode = oldMode
|
||||||
|
skillSetupResolveSource = oldSource
|
||||||
|
skillSetupResolveTargets = oldTargets
|
||||||
|
skillSetupListMulti = oldList
|
||||||
|
skillSetupFilterMulti = oldFilter
|
||||||
|
skillSetupConfirm = oldConfirm
|
||||||
|
skillSetupInstallMono = oldMono
|
||||||
|
skillSetupInstallMulti = oldMulti
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||||
|
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||||
|
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{"dest"}, nil }
|
||||||
|
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||||
|
|
||||||
|
skillSetupListMulti = func(string) ([]string, error) { return nil, fail }
|
||||||
|
if err := skillSetupCoverageCommand(t, skillSetupModeMulti, true).RunE(skillSetupCoverageCommand(t, skillSetupModeMulti, true), nil); err == nil {
|
||||||
|
t.Fatal("multi list failure should propagate")
|
||||||
|
}
|
||||||
|
skillSetupListMulti = func(string) ([]string, error) { return nil, nil }
|
||||||
|
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||||
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
t.Fatal("empty multi source should fail")
|
||||||
|
}
|
||||||
|
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
|
||||||
|
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||||
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
t.Fatal("multi filter failure should propagate")
|
||||||
|
}
|
||||||
|
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||||
|
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
|
||||||
|
if err := cmd.RunE(cmd, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||||
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
t.Fatal("confirmation failure should propagate")
|
||||||
|
}
|
||||||
|
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||||
|
if err := cmd.RunE(cmd, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
skillSetupResolveMode = func(string, bool, io.Writer) (string, error) { return "unknown", nil }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||||
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
t.Fatal("unknown resolved mode should fail")
|
||||||
|
}
|
||||||
|
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||||
|
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||||
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
t.Fatal("mono install failure should propagate")
|
||||||
|
}
|
||||||
|
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||||
|
if err := cmd.RunE(cmd, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||||
|
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||||
|
if err := cmd.RunE(cmd, nil); err == nil {
|
||||||
|
t.Fatal("multi install failure should propagate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillSetupLowLevelRemainingCoverage(t *testing.T) {
|
||||||
|
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
|
||||||
|
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||||
|
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||||
|
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||||
|
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||||
|
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||||
|
t.Cleanup(func() {
|
||||||
|
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
|
||||||
|
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||||
|
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||||
|
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||||
|
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||||
|
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
|
||||||
|
skillSetupInteractive = func() bool { return true }
|
||||||
|
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||||
|
if _, err := resolveSkillSetupMode("", false, io.Discard); err == nil {
|
||||||
|
t.Fatal("interactive mode failure should propagate")
|
||||||
|
}
|
||||||
|
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||||
|
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||||
|
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := writeMultiSkillSource(t, []string{"dingtalk-doc"})
|
||||||
|
if err := os.WriteFile(filepath.Join(source, "README"), []byte("file"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := listMultiSkillNames(source); err != nil || len(got) != 1 {
|
||||||
|
t.Fatalf("listed multi skills = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_SKILL_SOURCE", source)
|
||||||
|
if got, err := resolveSkillSetupSource("", skillSetupModeMulti); err != nil || got != source {
|
||||||
|
t.Fatalf("environment skill source = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||||
|
legacyRoot := t.TempDir()
|
||||||
|
legacyMono := filepath.Join(legacyRoot, "skills", skillSetupModeMono)
|
||||||
|
if err := os.MkdirAll(legacyMono, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(legacyMono, "SKILL.md"), []byte("skill"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skillSetupExecutable = func() (string, error) { return filepath.Join(legacyRoot, "dws"), nil }
|
||||||
|
skillSetupStat = oldStat
|
||||||
|
if got, err := resolveSkillSetupSource("", skillSetupModeMono); err != nil || got != legacyMono {
|
||||||
|
t.Fatalf("legacy executable source = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_SKILL_SOURCE", "env-source")
|
||||||
|
if got := skillSourceCandidates("", skillSetupModeMono); len(got) < 2 {
|
||||||
|
t.Fatalf("environment source candidates = %#v", got)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_SKILL_SOURCE", "")
|
||||||
|
skillSetupExecutable = func() (string, error) { return "", fail }
|
||||||
|
skillSetupGetwd = func() (string, error) { return "", fail }
|
||||||
|
skillSetupUserHomeDir = func() (string, error) { return "", fail }
|
||||||
|
if _, err := resolveSkillSetupSource("", skillSetupModeMono); err == nil {
|
||||||
|
t.Fatal("missing fallback source should fail")
|
||||||
|
}
|
||||||
|
_ = skillSourceCandidates("explicit", skillSetupModeMono)
|
||||||
|
|
||||||
|
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, fail }
|
||||||
|
if isSkillSourceRoot("missing", skillSetupModeMulti) {
|
||||||
|
t.Fatal("unreadable multi source accepted")
|
||||||
|
}
|
||||||
|
skillSetupUserHomeDir = func() (string, error) { return "", fail }
|
||||||
|
if _, err := resolveSkillSetupTargets("all", skillSetupModeMono); err == nil {
|
||||||
|
t.Fatal("HOME failure should propagate")
|
||||||
|
}
|
||||||
|
|
||||||
|
monoDest := filepath.Join(t.TempDir(), "skills", "dws")
|
||||||
|
multiRoot := filepath.Dir(monoDest)
|
||||||
|
if err := os.MkdirAll(filepath.Join(multiRoot, "dingtalk-doc"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||||
|
var out, errOut bytes.Buffer
|
||||||
|
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||||
|
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||||
|
t.Fatal("confirmation form failure should propagate")
|
||||||
|
}
|
||||||
|
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||||
|
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||||
|
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
skillSetupRemoveAll = func(string) error { return fail }
|
||||||
|
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||||
|
|
||||||
|
skillSetupCopyDir = func(string, string) error { return fail }
|
||||||
|
skillSetupRemoveAll = func(string) error { return fail }
|
||||||
|
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Fatal("mono remove failure not skipped")
|
||||||
|
}
|
||||||
|
skillSetupRemoveAll = func(string) error { return nil }
|
||||||
|
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Fatal("mono mkdir failure not skipped")
|
||||||
|
}
|
||||||
|
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
_, skipped, _ = installSkillToHomes("src", []string{"c"}, &out, &errOut)
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Fatal("mono copy failure not skipped")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"dest"}, &out, &errOut)
|
||||||
|
if skipped != 2 {
|
||||||
|
t.Fatal("multi mkdir failure count mismatch")
|
||||||
|
}
|
||||||
|
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
skillSetupRemoveAll = func(string) error { return fail }
|
||||||
|
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Fatal("multi remove failure count mismatch")
|
||||||
|
}
|
||||||
|
skillSetupRemoveAll = func(string) error { return nil }
|
||||||
|
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||||
|
if skipped != 1 {
|
||||||
|
t.Fatal("multi copy failure count mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillSetupWalk = func(string, filepath.WalkFunc) error { return fail }
|
||||||
|
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("walk failure = %v", err)
|
||||||
|
}
|
||||||
|
skillSetupWalk = func(_ string, fn filepath.WalkFunc) error {
|
||||||
|
return fn("path", skillSetupFileInfo{name: "path"}, nil)
|
||||||
|
}
|
||||||
|
skillSetupRel = func(string, string) (string, error) { return "", fail }
|
||||||
|
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("relative-path failure = %v", err)
|
||||||
|
}
|
||||||
|
skillSetupRel = func(string, string) (string, error) { return "file", nil }
|
||||||
|
skillSetupWalk = func(_ string, fn filepath.WalkFunc) error {
|
||||||
|
return fn("link", skillSetupFileInfo{name: "link", mode: os.ModeSymlink}, nil)
|
||||||
|
}
|
||||||
|
skillSetupReadlink = func(string) (string, error) { return "", fail }
|
||||||
|
if err := copyDir("src", "dst"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("readlink failure = %v", err)
|
||||||
|
}
|
||||||
|
for _, target := range []string{"relative-target", "/absolute-target"} {
|
||||||
|
skillSetupReadlink = func(string) (string, error) { return target, nil }
|
||||||
|
_ = copyDir("src", "dst")
|
||||||
|
}
|
||||||
|
|
||||||
|
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||||
|
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("copy mkdir failure = %v", err)
|
||||||
|
}
|
||||||
|
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
skillSetupOpen = func(string) (*os.File, error) { return nil, fail }
|
||||||
|
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("copy open failure = %v", err)
|
||||||
|
}
|
||||||
|
in, err := os.CreateTemp(t.TempDir(), "in")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skillSetupOpen = func(string) (*os.File, error) { return in, nil }
|
||||||
|
skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail }
|
||||||
|
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("copy output-open failure = %v", err)
|
||||||
|
}
|
||||||
|
outFile, err := os.CreateTemp(t.TempDir(), "out")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skillSetupOpen = func(string) (*os.File, error) { return in, nil }
|
||||||
|
skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return outFile, nil }
|
||||||
|
skillSetupCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail }
|
||||||
|
if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("copy content failure = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
closed, err := os.CreateTemp(t.TempDir(), "closed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = closed.Close()
|
||||||
|
if isCharDevice(closed) {
|
||||||
|
t.Fatal("closed file is not a character device")
|
||||||
|
}
|
||||||
|
_ = fs.ValidPath("path")
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const stdioEndpointScheme = "stdio://"
|
|||||||
var (
|
var (
|
||||||
stdioMu sync.RWMutex
|
stdioMu sync.RWMutex
|
||||||
stdioClients = make(map[string]*transport.StdioClient)
|
stdioClients = make(map[string]*transport.StdioClient)
|
||||||
|
stopStdio = func(client *transport.StdioClient) error { return client.Stop() }
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterStdioClient stores a StdioClient keyed by its canonical product ID
|
// RegisterStdioClient stores a StdioClient keyed by its canonical product ID
|
||||||
@@ -75,7 +76,7 @@ func StopAllStdioClients() {
|
|||||||
stdioMu.Lock()
|
stdioMu.Lock()
|
||||||
defer stdioMu.Unlock()
|
defer stdioMu.Unlock()
|
||||||
for id, client := range stdioClients {
|
for id, client := range stdioClients {
|
||||||
if err := client.Stop(); err != nil {
|
if err := stopStdio(client); err != nil {
|
||||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,7 @@ func StopStdioClient(productID string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := client.Stop(); err != nil {
|
if err := stopStdio(client); err != nil {
|
||||||
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
|
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
|
||||||
}
|
}
|
||||||
delete(stdioClients, productID)
|
delete(stdioClients, productID)
|
||||||
@@ -108,7 +109,7 @@ func StopStdioClientsByPlugin(pluginName string) int {
|
|||||||
count := 0
|
count := 0
|
||||||
for id, client := range stdioClients {
|
for id, client := range stdioClients {
|
||||||
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
|
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
|
||||||
if err := client.Stop(); err != nil {
|
if err := stopStdio(client); err != nil {
|
||||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||||
}
|
}
|
||||||
delete(stdioClients, id)
|
delete(stdioClients, id)
|
||||||
|
|||||||
+17
-7
@@ -55,6 +55,16 @@ const (
|
|||||||
perfReportFile = "latest.json"
|
perfReportFile = "latest.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
timingMarshalIndent = json.MarshalIndent
|
||||||
|
timingMkdirAll = os.MkdirAll
|
||||||
|
timingWriteFile = os.WriteFile
|
||||||
|
timingRemove = os.Remove
|
||||||
|
timingRename = os.Rename
|
||||||
|
timingReadFile = os.ReadFile
|
||||||
|
timingUserHomeDir = os.UserHomeDir
|
||||||
|
)
|
||||||
|
|
||||||
// timingContextKey is the context key for TimingCollector.
|
// timingContextKey is the context key for TimingCollector.
|
||||||
type timingContextKey struct{}
|
type timingContextKey struct{}
|
||||||
|
|
||||||
@@ -295,7 +305,7 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
report := tc.BuildReport(cliVersion, command)
|
report := tc.BuildReport(cliVersion, command)
|
||||||
data, err := json.MarshalIndent(report, "", " ")
|
data, err := timingMarshalIndent(report, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -306,22 +316,22 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
if err := timingMkdirAll(dir, 0o700); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp := path + ".tmp"
|
tmp := path + ".tmp"
|
||||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
if err := timingWriteFile(tmp, data, 0o600); err != nil {
|
||||||
_ = os.Remove(tmp)
|
_ = timingRemove(tmp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = os.Rename(tmp, path)
|
_ = timingRename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
|
// LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json).
|
||||||
func LoadLatestReport() (*PerfReport, error) {
|
func LoadLatestReport() (*PerfReport, error) {
|
||||||
path := defaultPerfReportPath()
|
path := defaultPerfReportPath()
|
||||||
data, err := os.ReadFile(path)
|
data, err := timingReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -341,7 +351,7 @@ func resolvePerfReportPath(dest string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultPerfReportPath() string {
|
func defaultPerfReportPath() string {
|
||||||
home, err := os.UserHomeDir()
|
home, err := timingUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-46
@@ -31,6 +31,53 @@ var (
|
|||||||
ugBoldGrn = tui.Success
|
ugBoldGrn = tui.Success
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type upgradeReleaseClient interface {
|
||||||
|
FetchLatestReleaseForTrack(upgrade.ReleaseTrack) (*upgrade.ReleaseInfo, error)
|
||||||
|
FetchReleaseByTag(string) (*upgrade.ReleaseInfo, error)
|
||||||
|
FetchReleaseVersions(upgrade.ReleaseTrack) ([]upgrade.VersionEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type upgradeRollbackManager interface {
|
||||||
|
ListBackups() ([]upgrade.BackupInfo, error)
|
||||||
|
RollbackTo(upgrade.BackupInfo) error
|
||||||
|
Backup(string) (string, error)
|
||||||
|
Cleanup(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
newUpgradeReleaseClient = func() upgradeReleaseClient { return upgrade.NewClient() }
|
||||||
|
newUpgradeRollback = func() upgradeRollbackManager { return upgrade.NewRollbackManager() }
|
||||||
|
ensureUpgradeDirs = upgrade.EnsureUpgradeDirectories
|
||||||
|
cleanupUpgradeStale = upgrade.CleanupStaleFiles
|
||||||
|
upgradeNeedsUpgrade = upgrade.NeedsUpgrade
|
||||||
|
findUpgradeBinary = upgrade.FindBinaryAsset
|
||||||
|
findUpgradeSkills = upgrade.FindSkillsAsset
|
||||||
|
findUpgradeChecksums = upgrade.FindChecksumsAsset
|
||||||
|
downloadUpgradeFile = upgrade.Download
|
||||||
|
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||||
|
extractUpgradeZip = upgrade.ExtractZip
|
||||||
|
findExtractedBinary = upgrade.FindBinaryInDir
|
||||||
|
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||||
|
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||||
|
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||||
|
upgradeMkdirTemp = os.MkdirTemp
|
||||||
|
upgradeRemoveAll = os.RemoveAll
|
||||||
|
upgradeReadFile = os.ReadFile
|
||||||
|
upgradeMkdirAll = os.MkdirAll
|
||||||
|
verifyUpgradeFile = strictVerifyFile
|
||||||
|
extractUpgradeTarGz = extractTarGz
|
||||||
|
validateUpgradeBinary = validateNewBinary
|
||||||
|
upgradeStat = os.Stat
|
||||||
|
upgradeChmod = os.Chmod
|
||||||
|
upgradeTryExecVersion = tryExecVersion
|
||||||
|
upgradeRepairDarwin = repairDarwinBinary
|
||||||
|
upgradeLookPath = exec.LookPath
|
||||||
|
upgradeCommandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.Command(name, args...).CombinedOutput()
|
||||||
|
}
|
||||||
|
upgradeUserHomeDir = os.UserHomeDir
|
||||||
|
)
|
||||||
|
|
||||||
const defaultListLimit = 10
|
const defaultListLimit = 10
|
||||||
|
|
||||||
func newUpgradeCommand() *cobra.Command {
|
func newUpgradeCommand() *cobra.Command {
|
||||||
@@ -128,7 +175,7 @@ type upgradeOptions struct {
|
|||||||
// --- dws upgrade --check ---
|
// --- dws upgrade --check ---
|
||||||
|
|
||||||
func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error {
|
func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error {
|
||||||
client := upgrade.NewClient()
|
client := newUpgradeReleaseClient()
|
||||||
|
|
||||||
if format != "json" {
|
if format != "json" {
|
||||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track))))
|
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track))))
|
||||||
@@ -140,7 +187,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentVer := version
|
currentVer := version
|
||||||
needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version)
|
needsUpgrade := upgradeNeedsUpgrade(currentVer, latest.Version)
|
||||||
|
|
||||||
if format == "json" {
|
if format == "json" {
|
||||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||||
@@ -184,7 +231,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra
|
|||||||
// runUpgradeList displays available versions. When limit > 0, only the most
|
// runUpgradeList displays available versions. When limit > 0, only the most
|
||||||
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
||||||
func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error {
|
func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error {
|
||||||
client := upgrade.NewClient()
|
client := newUpgradeReleaseClient()
|
||||||
|
|
||||||
if format != "json" {
|
if format != "json" {
|
||||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track))))
|
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track))))
|
||||||
@@ -264,7 +311,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.
|
|||||||
// --- dws upgrade --rollback ---
|
// --- dws upgrade --rollback ---
|
||||||
|
|
||||||
func runUpgradeRollback(yes bool) error {
|
func runUpgradeRollback(yes bool) error {
|
||||||
rm := upgrade.NewRollbackManager()
|
rm := newUpgradeRollback()
|
||||||
|
|
||||||
backups, err := rm.ListBackups()
|
backups, err := rm.ListBackups()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -341,13 +388,13 @@ func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills
|
|||||||
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track))))
|
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track))))
|
||||||
|
|
||||||
if err := upgrade.EnsureUpgradeDirectories(); err != nil {
|
if err := ensureUpgradeDirs(); err != nil {
|
||||||
return fmt.Errorf("初始化目录结构失败: %w", err)
|
return fmt.Errorf("初始化目录结构失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
upgrade.CleanupStaleFiles()
|
cleanupUpgradeStale()
|
||||||
|
|
||||||
client := upgrade.NewClient()
|
client := newUpgradeReleaseClient()
|
||||||
var release *upgrade.ReleaseInfo
|
var release *upgrade.ReleaseInfo
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -365,7 +412,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentVer := version
|
currentVer := version
|
||||||
if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) {
|
if !opts.force && !upgradeNeedsUpgrade(currentVer, release.Version) {
|
||||||
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -384,11 +431,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
// any side effect (no backup, no download, no replace). Matches the global
|
// any side effect (no backup, no download, no replace). Matches the global
|
||||||
// flag's contract: "预览操作内容,不实际执行".
|
// flag's contract: "预览操作内容,不实际执行".
|
||||||
if opts.dryRun {
|
if opts.dryRun {
|
||||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
binaryAsset, err := findUpgradeBinary(release.Assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills
|
||||||
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
|
writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -404,21 +451,21 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binaryAsset, err := upgrade.FindBinaryAsset(release.Assets)
|
binaryAsset, err := findUpgradeBinary(release.Assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
|
tmpDir, err := upgradeMkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tmpDir, err = os.MkdirTemp("", "dws-upgrade-*")
|
tmpDir, err = upgradeMkdirTemp("", "dws-upgrade-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("创建临时目录失败: %w", err)
|
return fmt.Errorf("创建临时目录失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer upgradeRemoveAll(tmpDir)
|
||||||
|
|
||||||
hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills
|
hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills
|
||||||
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
|
// Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装
|
||||||
const totalSteps = 5
|
const totalSteps = 5
|
||||||
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
|
stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) }
|
||||||
@@ -431,7 +478,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
|
|
||||||
// --- Step 1: Backup ---
|
// --- Step 1: Backup ---
|
||||||
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
|
fmt.Printf(" %s 备份当前版本...", stepFmt(1))
|
||||||
rm := upgrade.NewRollbackManager()
|
rm := newUpgradeRollback()
|
||||||
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
|
_, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v"))
|
||||||
if backupErr != nil {
|
if backupErr != nil {
|
||||||
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
|
fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr)
|
||||||
@@ -441,11 +488,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
|
|
||||||
// Fetch checksums.txt (needed for strict verification of both binary and skills)
|
// Fetch checksums.txt (needed for strict verification of both binary and skills)
|
||||||
var checksumsContent string
|
var checksumsContent string
|
||||||
checksumsAsset := upgrade.FindChecksumsAsset(release.Assets)
|
checksumsAsset := findUpgradeChecksums(release.Assets)
|
||||||
if checksumsAsset != nil {
|
if checksumsAsset != nil {
|
||||||
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
|
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
|
||||||
if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
|
if _, dlErr := downloadUpgradeFile(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil {
|
||||||
if data, readErr := os.ReadFile(checksumsPath); readErr == nil {
|
if data, readErr := upgradeReadFile(checksumsPath); readErr == nil {
|
||||||
checksumsContent = string(data)
|
checksumsContent = string(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,7 +504,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
fmt.Print(progressPrefix)
|
fmt.Print(progressPrefix)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
|
binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name)
|
||||||
n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
|
n, err := downloadUpgradeProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath,
|
||||||
func(percent float64, downloaded, total int64) {
|
func(percent float64, downloaded, total int64) {
|
||||||
bar := progressBar(percent)
|
bar := progressBar(percent)
|
||||||
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
|
fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent)
|
||||||
@@ -471,12 +518,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
|
|
||||||
var skillsZipPath string
|
var skillsZipPath string
|
||||||
if hasSkills {
|
if hasSkills {
|
||||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
skillsAsset := findUpgradeSkills(release.Assets)
|
||||||
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds())))
|
||||||
|
|
||||||
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
|
fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip"))
|
||||||
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
|
skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip")
|
||||||
if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
|
if _, dlErr := downloadUpgradeFile(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil {
|
||||||
fmt.Printf(" %s\n", ugRed("✗"))
|
fmt.Printf(" %s\n", ugRed("✗"))
|
||||||
return fmt.Errorf("技能包下载失败: %w", dlErr)
|
return fmt.Errorf("技能包下载失败: %w", dlErr)
|
||||||
}
|
}
|
||||||
@@ -486,12 +533,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 3: Verify SHA256 (binary + skills together) ---
|
// --- Step 3: Verify SHA256 (binary + skills together) ---
|
||||||
if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
|
if err := verifyUpgradeFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if hasSkills {
|
if hasSkills {
|
||||||
skillsAsset := upgrade.FindSkillsAsset(release.Assets)
|
skillsAsset := findUpgradeSkills(release.Assets)
|
||||||
if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
|
if err := verifyUpgradeFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,22 +547,22 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
fmt.Printf(" %s 解压并验证...", stepFmt(4))
|
fmt.Printf(" %s 解压并验证...", stepFmt(4))
|
||||||
extractDir := filepath.Join(tmpDir, "extracted")
|
extractDir := filepath.Join(tmpDir, "extracted")
|
||||||
if strings.HasSuffix(binaryAsset.Name, ".zip") {
|
if strings.HasSuffix(binaryAsset.Name, ".zip") {
|
||||||
if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil {
|
if err := extractUpgradeZip(binaryArchivePath, extractDir); err != nil {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return fmt.Errorf("解压失败: %w", err)
|
return fmt.Errorf("解压失败: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := extractTarGz(binaryArchivePath, extractDir); err != nil {
|
if err := extractUpgradeTarGz(binaryArchivePath, extractDir); err != nil {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return fmt.Errorf("解压失败: %w", err)
|
return fmt.Errorf("解压失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binaryPath := upgrade.FindBinaryInDir(extractDir)
|
binaryPath := findExtractedBinary(extractDir)
|
||||||
if binaryPath == "" {
|
if binaryPath == "" {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
|
return fmt.Errorf("在解压目录中未找到 dws 二进制文件")
|
||||||
}
|
}
|
||||||
if err := validateNewBinary(binaryPath, release.Version); err != nil {
|
if err := validateUpgradeBinary(binaryPath, release.Version); err != nil {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return fmt.Errorf("验证失败: %w", err)
|
return fmt.Errorf("验证失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -523,12 +570,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
var skillSrc string
|
var skillSrc string
|
||||||
if hasSkills {
|
if hasSkills {
|
||||||
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
|
skillsExtractDir := filepath.Join(tmpDir, "skills-extracted")
|
||||||
os.MkdirAll(skillsExtractDir, 0755)
|
_ = upgradeMkdirAll(skillsExtractDir, 0755)
|
||||||
if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil {
|
if err := extractUpgradeZip(skillsZipPath, skillsExtractDir); err != nil {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
|
return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err)
|
||||||
}
|
}
|
||||||
skillSrc = upgrade.LocateSkillMD(skillsExtractDir)
|
skillSrc = locateUpgradeSkill(skillsExtractDir)
|
||||||
if skillSrc == "" {
|
if skillSrc == "" {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
|
return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues")
|
||||||
@@ -542,13 +589,13 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
|||||||
|
|
||||||
// --- Step 5: Replace binary + install skills ---
|
// --- Step 5: Replace binary + install skills ---
|
||||||
fmt.Printf(" %s 替换并安装...", stepFmt(5))
|
fmt.Printf(" %s 替换并安装...", stepFmt(5))
|
||||||
if err := upgrade.ReplaceSelf(binaryPath); err != nil {
|
if err := replaceUpgradeSelf(binaryPath); err != nil {
|
||||||
fmt.Printf(" %s\n", ugRed("✗"))
|
fmt.Printf(" %s\n", ugRed("✗"))
|
||||||
return fmt.Errorf("替换二进制失败: %w", err)
|
return fmt.Errorf("替换二进制失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasSkills {
|
if hasSkills {
|
||||||
result, installErr := upgrade.UpgradeSkillLocations(skillSrc)
|
result, installErr := installUpgradeSkills(skillSrc)
|
||||||
if installErr != nil {
|
if installErr != nil {
|
||||||
fmt.Printf(" %s\n", ugRed("✗"))
|
fmt.Printf(" %s\n", ugRed("✗"))
|
||||||
return fmt.Errorf("技能包安装失败: %w", installErr)
|
return fmt.Errorf("技能包安装失败: %w", installErr)
|
||||||
@@ -626,24 +673,24 @@ func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent s
|
|||||||
|
|
||||||
// validateNewBinary checks the downloaded binary is valid.
|
// validateNewBinary checks the downloaded binary is valid.
|
||||||
func validateNewBinary(binaryPath, expectedVersion string) error {
|
func validateNewBinary(binaryPath, expectedVersion string) error {
|
||||||
info, err := os.Stat(binaryPath)
|
info, err := upgradeStat(binaryPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("文件不存在: %w", err)
|
return fmt.Errorf("文件不存在: %w", err)
|
||||||
}
|
}
|
||||||
if info.Size() == 0 {
|
if info.Size() == 0 {
|
||||||
return fmt.Errorf("文件为空")
|
return fmt.Errorf("文件为空")
|
||||||
}
|
}
|
||||||
if err := os.Chmod(binaryPath, 0755); err != nil {
|
if err := upgradeChmod(binaryPath, 0755); err != nil {
|
||||||
return fmt.Errorf("设置执行权限失败: %w", err)
|
return fmt.Errorf("设置执行权限失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := tryExecVersion(binaryPath)
|
out, err := upgradeTryExecVersion(binaryPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
|
// Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid.
|
||||||
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
|
// Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once.
|
||||||
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
|
if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) {
|
||||||
if repairErr := repairDarwinBinary(binaryPath); repairErr == nil {
|
if repairErr := upgradeRepairDarwin(binaryPath); repairErr == nil {
|
||||||
out, err = tryExecVersion(binaryPath)
|
out, err = upgradeTryExecVersion(binaryPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -678,12 +725,12 @@ func isLikelyAMFIKill(err error) bool {
|
|||||||
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
|
// Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon.
|
||||||
func repairDarwinBinary(binaryPath string) error {
|
func repairDarwinBinary(binaryPath string) error {
|
||||||
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
|
// Best-effort: strip quarantine. Failure is fine (attribute often absent).
|
||||||
_ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run()
|
_, _ = upgradeCommandOutput("xattr", "-d", "com.apple.quarantine", binaryPath)
|
||||||
|
|
||||||
if _, err := exec.LookPath("codesign"); err != nil {
|
if _, err := upgradeLookPath("codesign"); err != nil {
|
||||||
return fmt.Errorf("codesign 不可用: %w", err)
|
return fmt.Errorf("codesign 不可用: %w", err)
|
||||||
}
|
}
|
||||||
out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput()
|
out, err := upgradeCommandOutput("codesign", "--force", "--sign", "-", binaryPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
|
return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
@@ -692,9 +739,8 @@ func repairDarwinBinary(binaryPath string) error {
|
|||||||
|
|
||||||
// extractTarGz extracts a .tar.gz file using the system tar command.
|
// extractTarGz extracts a .tar.gz file using the system tar command.
|
||||||
func extractTarGz(archivePath, destDir string) error {
|
func extractTarGz(archivePath, destDir string) error {
|
||||||
os.MkdirAll(destDir, 0755)
|
_ = upgradeMkdirAll(destDir, 0755)
|
||||||
cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir)
|
if out, err := upgradeCommandOutput("tar", "xzf", archivePath, "-C", destDir); err != nil {
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
|
return fmt.Errorf("tar 解压失败: %v: %s", err, string(out))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -867,7 +913,7 @@ func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shortenHome(path string) string {
|
func shortenHome(path string) string {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := upgradeUserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeUpgradeClient struct {
|
||||||
|
latest *upgradepkg.ReleaseInfo
|
||||||
|
latestErr error
|
||||||
|
tagged *upgradepkg.ReleaseInfo
|
||||||
|
taggedErr error
|
||||||
|
versions []upgradepkg.VersionEntry
|
||||||
|
versionsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeUpgradeClient) FetchLatestReleaseForTrack(upgradepkg.ReleaseTrack) (*upgradepkg.ReleaseInfo, error) {
|
||||||
|
return c.latest, c.latestErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeUpgradeClient) FetchReleaseByTag(string) (*upgradepkg.ReleaseInfo, error) {
|
||||||
|
return c.tagged, c.taggedErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeUpgradeClient) FetchReleaseVersions(upgradepkg.ReleaseTrack) ([]upgradepkg.VersionEntry, error) {
|
||||||
|
return c.versions, c.versionsErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeUpgradeRollback struct {
|
||||||
|
backups []upgradepkg.BackupInfo
|
||||||
|
listErr error
|
||||||
|
backupErr error
|
||||||
|
rollbackErr error
|
||||||
|
cleaned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeUpgradeRollback) ListBackups() ([]upgradepkg.BackupInfo, error) {
|
||||||
|
return r.backups, r.listErr
|
||||||
|
}
|
||||||
|
func (r *fakeUpgradeRollback) RollbackTo(upgradepkg.BackupInfo) error { return r.rollbackErr }
|
||||||
|
func (r *fakeUpgradeRollback) Backup(string) (string, error) { return "backup", r.backupErr }
|
||||||
|
func (r *fakeUpgradeRollback) Cleanup(int) error {
|
||||||
|
r.cleaned = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type upgradeFileInfo struct{ size int64 }
|
||||||
|
|
||||||
|
func (i upgradeFileInfo) Name() string { return "dws" }
|
||||||
|
func (i upgradeFileInfo) Size() int64 { return i.size }
|
||||||
|
func (i upgradeFileInfo) Mode() os.FileMode { return 0o755 }
|
||||||
|
func (i upgradeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (i upgradeFileInfo) IsDir() bool { return false }
|
||||||
|
func (i upgradeFileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
|
func TestUpgradeRollbackAndCommandBranchesCoverage(t *testing.T) {
|
||||||
|
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
oldVersion := version
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
version = oldVersion
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
rb := &fakeUpgradeRollback{listErr: fail}
|
||||||
|
newUpgradeRollback = func() upgradeRollbackManager { return rb }
|
||||||
|
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("rollback list error = %v", err)
|
||||||
|
}
|
||||||
|
rb.listErr = nil
|
||||||
|
if err := runUpgradeRollback(true); err == nil {
|
||||||
|
t.Fatal("rollback without backups succeeded")
|
||||||
|
}
|
||||||
|
rb.backups = []upgradepkg.BackupInfo{{Version: "1.0.0", CreatedAt: time.Now()}}
|
||||||
|
read, write, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(write, "n\n")
|
||||||
|
_ = write.Close()
|
||||||
|
os.Stdin = read
|
||||||
|
if err := runUpgradeRollback(false); err != nil {
|
||||||
|
t.Fatalf("cancel rollback = %v", err)
|
||||||
|
}
|
||||||
|
_ = read.Close()
|
||||||
|
rb.rollbackErr = fail
|
||||||
|
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("rollback apply error = %v", err)
|
||||||
|
}
|
||||||
|
rb.rollbackErr = nil
|
||||||
|
if err := runUpgradeRollback(true); err != nil {
|
||||||
|
t.Fatalf("rollback success = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeUpgradeClient{
|
||||||
|
latest: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
|
||||||
|
tagged: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
|
||||||
|
versions: []upgradepkg.VersionEntry{{Version: "9.9.9"}, {Version: "9.9.8"}},
|
||||||
|
}
|
||||||
|
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
|
||||||
|
client.latestErr = fail
|
||||||
|
cmd := &cobra.Command{Use: "upgrade"}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
if err := runUpgradeCheck(cmd, "json", upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("upgrade check error = %v", err)
|
||||||
|
}
|
||||||
|
client.latestErr = nil
|
||||||
|
client.versionsErr = fail
|
||||||
|
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("upgrade list error = %v", err)
|
||||||
|
}
|
||||||
|
client.versionsErr = nil
|
||||||
|
version = "9.9.9"
|
||||||
|
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := runUpgradeList(cmd, "table", 1, upgradepkg.ReleaseTrackRelease); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||||
|
embedded := newUpgradeCommand()
|
||||||
|
if err := embedded.Execute(); err == nil || !strings.Contains(err.Error(), "embedded") {
|
||||||
|
t.Fatalf("unnamed embedded upgrade error = %v", err)
|
||||||
|
}
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
for _, args := range [][]string{{"--list", "--all"}, {"--rollback", "--yes"}, {"--check"}} {
|
||||||
|
command := newUpgradeCommand()
|
||||||
|
if args[0] == "--rollback" {
|
||||||
|
command.Flags().Bool("yes", false, "")
|
||||||
|
}
|
||||||
|
command.SetOut(io.Discard)
|
||||||
|
command.SetArgs(args)
|
||||||
|
if err := command.Execute(); err != nil {
|
||||||
|
t.Fatalf("upgrade command %v = %v", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||||
|
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
|
||||||
|
oldEnsure, oldCleanup := ensureUpgradeDirs, cleanupUpgradeStale
|
||||||
|
oldNeeds, oldBinary, oldSkills, oldChecksums := upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums
|
||||||
|
oldDownload, oldProgress := downloadUpgradeFile, downloadUpgradeProgress
|
||||||
|
oldExtract, oldFind, oldLocate := extractUpgradeZip, findExtractedBinary, locateUpgradeSkill
|
||||||
|
oldReplace, oldInstall := replaceUpgradeSelf, installUpgradeSkills
|
||||||
|
oldTemp, oldRemove, oldRead, oldMkdir := upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll
|
||||||
|
oldVerify, oldTar, oldValidate := verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
|
||||||
|
ensureUpgradeDirs, cleanupUpgradeStale = oldEnsure, oldCleanup
|
||||||
|
upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums = oldNeeds, oldBinary, oldSkills, oldChecksums
|
||||||
|
downloadUpgradeFile, downloadUpgradeProgress = oldDownload, oldProgress
|
||||||
|
extractUpgradeZip, findExtractedBinary, locateUpgradeSkill = oldExtract, oldFind, oldLocate
|
||||||
|
replaceUpgradeSelf, installUpgradeSkills = oldReplace, oldInstall
|
||||||
|
upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll = oldTemp, oldRemove, oldRead, oldMkdir
|
||||||
|
verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary = oldVerify, oldTar, oldValidate
|
||||||
|
os.Stdin = oldStdin
|
||||||
|
})
|
||||||
|
fail := errors.New("stage failure")
|
||||||
|
binary := upgradepkg.GitHubAsset{Name: "dws.zip", BrowserDownloadURL: "binary"}
|
||||||
|
skills := upgradepkg.GitHubAsset{Name: "dws-skills.zip", BrowserDownloadURL: "skills"}
|
||||||
|
checksums := upgradepkg.GitHubAsset{Name: "checksums.txt", BrowserDownloadURL: "checksums"}
|
||||||
|
release := &upgradepkg.ReleaseInfo{Version: "9.9.9", Date: "2026-01-01", Prerelease: true, Assets: []upgradepkg.GitHubAsset{binary, skills, checksums}}
|
||||||
|
client := &fakeUpgradeClient{latest: release, tagged: release}
|
||||||
|
rb := &fakeUpgradeRollback{}
|
||||||
|
|
||||||
|
configure := func(stage string) {
|
||||||
|
client.latestErr, client.taggedErr = nil, nil
|
||||||
|
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
|
||||||
|
newUpgradeRollback = func() upgradeRollbackManager { return rb }
|
||||||
|
rb.backupErr, rb.cleaned = nil, false
|
||||||
|
ensureUpgradeDirs = func() error {
|
||||||
|
if stage == "ensure" {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cleanupUpgradeStale = func() {}
|
||||||
|
upgradeNeedsUpgrade = func(string, string) bool { return stage != "not-needed" }
|
||||||
|
findUpgradeBinary = func([]upgradepkg.GitHubAsset) (*upgradepkg.GitHubAsset, error) {
|
||||||
|
if stage == "find-binary" {
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
asset := binary
|
||||||
|
if stage == "extract-tar" {
|
||||||
|
asset.Name = "dws.tar.gz"
|
||||||
|
}
|
||||||
|
return &asset, nil
|
||||||
|
}
|
||||||
|
findUpgradeSkills = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := skills; return &asset }
|
||||||
|
findUpgradeChecksums = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := checksums; return &asset }
|
||||||
|
tempCalls := 0
|
||||||
|
upgradeMkdirTemp = func(string, string) (string, error) {
|
||||||
|
tempCalls++
|
||||||
|
if stage == "temp-both" || (stage == "temp-fallback" && tempCalls == 1) {
|
||||||
|
return "", fail
|
||||||
|
}
|
||||||
|
return "/tmp/dws-upgrade-coverage", nil
|
||||||
|
}
|
||||||
|
upgradeRemoveAll = func(string) error { return nil }
|
||||||
|
if stage == "backup" {
|
||||||
|
rb.backupErr = fail
|
||||||
|
}
|
||||||
|
downloadUpgradeFile = func(url, _ string) (int64, error) {
|
||||||
|
if stage == "checksum-download" && url == "checksums" {
|
||||||
|
return 0, fail
|
||||||
|
}
|
||||||
|
if stage == "skills-download" && url == "skills" {
|
||||||
|
return 0, fail
|
||||||
|
}
|
||||||
|
return 4, nil
|
||||||
|
}
|
||||||
|
upgradeReadFile = func(string) ([]byte, error) {
|
||||||
|
if stage == "checksum-read" {
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
return []byte("checksum"), nil
|
||||||
|
}
|
||||||
|
downloadUpgradeProgress = func(_ context.Context, _, _ string, progress func(float64, int64, int64)) (int64, error) {
|
||||||
|
progress(150, 10, 10)
|
||||||
|
if stage == "binary-download" || stage == "temp-fallback" {
|
||||||
|
return 0, fail
|
||||||
|
}
|
||||||
|
return 1024, nil
|
||||||
|
}
|
||||||
|
verifyCalls := 0
|
||||||
|
verifyUpgradeFile = func(string, string, string, string, string) error {
|
||||||
|
verifyCalls++
|
||||||
|
if stage == "verify-binary" && verifyCalls == 1 || stage == "verify-skills" && verifyCalls == 2 {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extractCalls := 0
|
||||||
|
extractUpgradeZip = func(string, string) error {
|
||||||
|
extractCalls++
|
||||||
|
if stage == "extract-binary" && extractCalls == 1 || stage == "extract-skills" && extractCalls == 2 {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extractUpgradeTarGz = func(string, string) error {
|
||||||
|
if stage == "extract-tar" {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
findExtractedBinary = func(string) string {
|
||||||
|
if stage == "binary-missing" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/tmp/new-dws"
|
||||||
|
}
|
||||||
|
validateUpgradeBinary = func(string, string) error {
|
||||||
|
if stage == "validate" {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
locateUpgradeSkill = func(string) string {
|
||||||
|
if stage == "skill-missing" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/tmp/SKILL.md"
|
||||||
|
}
|
||||||
|
replaceUpgradeSelf = func(string) error {
|
||||||
|
if stage == "replace" {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
|
||||||
|
if stage == "install" {
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
if stage == "install-failed-dir" {
|
||||||
|
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
|
||||||
|
}
|
||||||
|
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stage := range []string{
|
||||||
|
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
|
||||||
|
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
|
||||||
|
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
|
||||||
|
"success", "success-no-skills",
|
||||||
|
} {
|
||||||
|
t.Run(stage, func(t *testing.T) {
|
||||||
|
configure(stage)
|
||||||
|
opts := upgradeOptions{force: true, yes: true}
|
||||||
|
if stage == "tag-error" {
|
||||||
|
opts.targetVersion = "v9.9.9"
|
||||||
|
client.taggedErr = fail
|
||||||
|
}
|
||||||
|
if stage == "latest-error" {
|
||||||
|
client.latestErr = fail
|
||||||
|
}
|
||||||
|
if stage == "not-needed" {
|
||||||
|
opts.force = false
|
||||||
|
}
|
||||||
|
if stage == "cancel" {
|
||||||
|
opts.yes = false
|
||||||
|
read, write, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(write, "n\n")
|
||||||
|
_ = write.Close()
|
||||||
|
os.Stdin = read
|
||||||
|
defer read.Close()
|
||||||
|
}
|
||||||
|
if stage == "success-no-skills" {
|
||||||
|
opts.skipSkills = true
|
||||||
|
}
|
||||||
|
err := runUpgrade(context.Background(), opts)
|
||||||
|
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
|
||||||
|
if wantError && err == nil {
|
||||||
|
t.Fatalf("stage %s succeeded", stage)
|
||||||
|
}
|
||||||
|
if !wantError && err != nil {
|
||||||
|
t.Fatalf("stage %s failed: %v", stage, err)
|
||||||
|
}
|
||||||
|
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
|
||||||
|
t.Fatal("successful upgrade did not clean backups")
|
||||||
|
}
|
||||||
|
if stage == "success" {
|
||||||
|
command := newUpgradeCommand()
|
||||||
|
command.Flags().Bool("yes", false, "")
|
||||||
|
command.Flags().Bool("dry-run", false, "")
|
||||||
|
_ = command.Flags().Set("yes", "true")
|
||||||
|
_ = command.Flags().Set("force", "true")
|
||||||
|
_ = command.Flags().Set("skip-skills", "true")
|
||||||
|
if err := command.RunE(command, nil); err != nil {
|
||||||
|
t.Fatalf("default upgrade command = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradeBinaryHelpersFailureCoverage(t *testing.T) {
|
||||||
|
oldStat, oldChmod := upgradeStat, upgradeChmod
|
||||||
|
oldTry, oldRepair := upgradeTryExecVersion, upgradeRepairDarwin
|
||||||
|
oldLook, oldCommand, oldMkdir, oldHome := upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir
|
||||||
|
t.Cleanup(func() {
|
||||||
|
upgradeStat, upgradeChmod = oldStat, oldChmod
|
||||||
|
upgradeTryExecVersion, upgradeRepairDarwin = oldTry, oldRepair
|
||||||
|
upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir = oldLook, oldCommand, oldMkdir, oldHome
|
||||||
|
})
|
||||||
|
fail := errors.New("failure")
|
||||||
|
upgradeStat = func(string) (os.FileInfo, error) { return nil, fail }
|
||||||
|
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("binary stat error = %v", err)
|
||||||
|
}
|
||||||
|
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{}, nil }
|
||||||
|
if err := validateNewBinary("binary", "1.0"); err == nil || !strings.Contains(err.Error(), "为空") {
|
||||||
|
t.Fatalf("empty binary error = %v", err)
|
||||||
|
}
|
||||||
|
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{size: 1}, nil }
|
||||||
|
upgradeChmod = func(string, os.FileMode) error { return fail }
|
||||||
|
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("binary chmod error = %v", err)
|
||||||
|
}
|
||||||
|
upgradeChmod = func(string, os.FileMode) error { return nil }
|
||||||
|
upgradeTryExecVersion = func(string) ([]byte, error) { return nil, fail }
|
||||||
|
upgradeRepairDarwin = func(string) error { return fail }
|
||||||
|
if err := validateNewBinary("binary", "1.0"); err == nil {
|
||||||
|
t.Fatal("unexecutable binary succeeded")
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
calls := 0
|
||||||
|
upgradeTryExecVersion = func(string) ([]byte, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return nil, errors.New("signal: killed")
|
||||||
|
}
|
||||||
|
return []byte("version 1.0"), nil
|
||||||
|
}
|
||||||
|
upgradeRepairDarwin = func(string) error { return nil }
|
||||||
|
if err := validateNewBinary("binary", "1.0"); err != nil {
|
||||||
|
t.Fatalf("repaired binary = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upgradeTryExecVersion = func(string) ([]byte, error) { return []byte("different"), nil }
|
||||||
|
if err := validateNewBinary("binary", "1.0"); err != nil {
|
||||||
|
t.Fatalf("version mismatch warning = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
upgradeLookPath = func(string) (string, error) { return "", fail }
|
||||||
|
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
|
||||||
|
if err := repairDarwinBinary("binary"); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("codesign lookup error = %v", err)
|
||||||
|
}
|
||||||
|
upgradeLookPath = func(string) (string, error) { return "/codesign", nil }
|
||||||
|
upgradeCommandOutput = func(name string, _ ...string) ([]byte, error) {
|
||||||
|
if name == "codesign" {
|
||||||
|
return []byte("denied"), fail
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := repairDarwinBinary("binary"); err == nil || !strings.Contains(err.Error(), "denied") {
|
||||||
|
t.Fatalf("codesign execution error = %v", err)
|
||||||
|
}
|
||||||
|
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
|
||||||
|
if err := repairDarwinBinary("binary"); err != nil {
|
||||||
|
t.Fatalf("codesign success = %v", err)
|
||||||
|
}
|
||||||
|
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail }
|
||||||
|
if err := extractTarGz("archive", "dest"); err == nil || !strings.Contains(err.Error(), "tar failed") {
|
||||||
|
t.Fatalf("tar error = %v", err)
|
||||||
|
}
|
||||||
|
upgradeUserHomeDir = func() (string, error) { return "", fail }
|
||||||
|
if shortenHome("/path") != "/path" {
|
||||||
|
t.Fatal("home lookup failure shortened path")
|
||||||
|
}
|
||||||
|
if got := parseChangelogEntries("abcdef12", 1); len(got) != 0 {
|
||||||
|
t.Fatalf("empty changelog entry = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var agoalLoadLocation = time.LoadLocation
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// dws agoal — Agoal 管理
|
// dws agoal — Agoal 管理
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
@@ -595,7 +597,7 @@ func parseISO8601ToMillis(value string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shanghaiLocation() *time.Location {
|
func shanghaiLocation() *time.Location {
|
||||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
loc, err := agoalLoadLocation("Asia/Shanghai")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.FixedZone("CST", 8*3600)
|
return time.FixedZone("CST", 8*3600)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgoalUpdateRemainingCoverage(t *testing.T) {
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
if err := executeFilterCoverage(t, newAgoalCommand(),
|
||||||
|
"strategy", "update", "--profile-id", "profile", "--content", `[]`, "--request-id", "request",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("strategy update: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newAgoalCommand(),
|
||||||
|
"contract", "update", "--contract-id", "contract", "--dimensions", `[]`,
|
||||||
|
"--request-id", "request", "--audit-config", `{}`, "--objective-template", `{}`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("contract update: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newAgoalCommand(),
|
||||||
|
"scorecard", "update", "--dept-id", "dept", "--selected-time", "bad", "--id", "card",
|
||||||
|
"--tracking-period-type", "MONTHLY", "--content", `[]`,
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("invalid scorecard time returned nil")
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newAgoalCommand(),
|
||||||
|
"scorecard", "update", "--dept-id", "dept", "--selected-time", "2026-01-01T00:00:00", "--id", "card",
|
||||||
|
"--tracking-period-type", "MONTHLY", "--content", `[]`, "--request-id", "request",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("scorecard update: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgoalLocationFallbackCoverage(t *testing.T) {
|
||||||
|
original := agoalLoadLocation
|
||||||
|
agoalLoadLocation = func(string) (*time.Location, error) { return nil, errors.New("zoneinfo unavailable") }
|
||||||
|
t.Cleanup(func() { agoalLoadLocation = original })
|
||||||
|
if got := shanghaiLocation(); got == nil || got.String() != "CST" {
|
||||||
|
t.Fatalf("fallback location = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAISearchRemainingFallbackBranches(t *testing.T) {
|
||||||
|
if got := flagValue(&cobra.Command{Use: "empty"}, "missing"); got != "" {
|
||||||
|
t.Fatalf("missing flag=%q", got)
|
||||||
|
}
|
||||||
|
if got := parseDimensions(""); len(got) != 1 || got[0] != "all" {
|
||||||
|
t.Fatalf("empty dimensions=%v", got)
|
||||||
|
}
|
||||||
|
if got := parseDimensions(" , "); len(got) != 1 || got[0] != "all" {
|
||||||
|
t.Fatalf("blank dimensions=%v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"--query", "alice"},
|
||||||
|
{"enterprise", "--types", ","},
|
||||||
|
{"behavior", "--types", ","},
|
||||||
|
} {
|
||||||
|
if err := executeFilterCoverage(t, newAisearchCommand(), args...); err != nil {
|
||||||
|
t.Fatalf("args=%v err=%v", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,23 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type atomicTempFile interface {
|
||||||
|
io.Writer
|
||||||
|
Name() string
|
||||||
|
Chmod(os.FileMode) error
|
||||||
|
Sync() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
atomicMkdirAll = os.MkdirAll
|
||||||
|
atomicCreateTemp = func(dir, pattern string) (atomicTempFile, error) {
|
||||||
|
return os.CreateTemp(dir, pattern)
|
||||||
|
}
|
||||||
|
atomicRemove = os.Remove
|
||||||
|
atomicRename = os.Rename
|
||||||
|
)
|
||||||
|
|
||||||
// AtomicWrite writes data to path atomically by creating a temp file in the
|
// AtomicWrite writes data to path atomically by creating a temp file in the
|
||||||
// same directory, writing and fsyncing the data, then renaming over the target.
|
// same directory, writing and fsyncing the data, then renaming over the target.
|
||||||
// It replaces os.WriteFile for all config and download file writes.
|
// It replaces os.WriteFile for all config and download file writes.
|
||||||
@@ -29,7 +46,7 @@ import (
|
|||||||
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
|
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
|
||||||
// original file remains untouched.
|
// original file remains untouched.
|
||||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
return atomicWrite(path, perm, func(tmp atomicTempFile) error {
|
||||||
_, err := tmp.Write(data)
|
_, err := tmp.Write(data)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
@@ -38,7 +55,7 @@ func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
|||||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||||
var copied int64
|
var copied int64
|
||||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
err := atomicWrite(path, perm, func(tmp atomicTempFile) error {
|
||||||
n, err := io.Copy(tmp, reader)
|
n, err := io.Copy(tmp, reader)
|
||||||
copied = n
|
copied = n
|
||||||
return err
|
return err
|
||||||
@@ -55,15 +72,15 @@ func AtomicWriteJSON(path string, data []byte) error {
|
|||||||
return AtomicWrite(path, data, 0600)
|
return AtomicWrite(path, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp atomicTempFile) error) error {
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
// Ensure directory exists with secure permissions
|
// Ensure directory exists with secure permissions
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
if err := atomicMkdirAll(dir, 0700); err != nil {
|
||||||
return fmt.Errorf("create directory: %w", err)
|
return fmt.Errorf("create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create temp file: %w", err)
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -73,7 +90,7 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error
|
|||||||
defer func() {
|
defer func() {
|
||||||
if !success {
|
if !success {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Remove(tmpName)
|
atomicRemove(tmpName)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -89,7 +106,7 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error
|
|||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return fmt.Errorf("close temp file: %w", err)
|
return fmt.Errorf("close temp file: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
if err := atomicRename(tmpName, path); err != nil {
|
||||||
return fmt.Errorf("rename to final: %w", err)
|
return fmt.Errorf("rename to final: %w", err)
|
||||||
}
|
}
|
||||||
success = true
|
success = true
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type atomicFakeTemp struct {
|
||||||
|
chmodErr error
|
||||||
|
writeErr error
|
||||||
|
syncErr error
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*atomicFakeTemp) Name() string { return "fake.tmp" }
|
||||||
|
func (f *atomicFakeTemp) Chmod(os.FileMode) error { return f.chmodErr }
|
||||||
|
func (f *atomicFakeTemp) Write(p []byte) (int, error) { return len(p), f.writeErr }
|
||||||
|
func (f *atomicFakeTemp) Sync() error { return f.syncErr }
|
||||||
|
func (f *atomicFakeTemp) Close() error { return f.closeErr }
|
||||||
|
|
||||||
|
type atomicErrorReader struct{}
|
||||||
|
|
||||||
|
func (atomicErrorReader) Read([]byte) (int, error) { return 0, errors.New("read") }
|
||||||
|
|
||||||
|
func TestAtomicWriteRemainingFailures(t *testing.T) {
|
||||||
|
origMkdir, origCreate := atomicMkdirAll, atomicCreateTemp
|
||||||
|
origRemove, origRename := atomicRemove, atomicRename
|
||||||
|
t.Cleanup(func() {
|
||||||
|
atomicMkdirAll, atomicCreateTemp = origMkdir, origCreate
|
||||||
|
atomicRemove, atomicRename = origRemove, origRename
|
||||||
|
})
|
||||||
|
|
||||||
|
atomicMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
|
||||||
|
if err := AtomicWrite("out/file", nil, 0o600); err == nil || !strings.Contains(err.Error(), "create directory") {
|
||||||
|
t.Fatalf("mkdir err=%v", err)
|
||||||
|
}
|
||||||
|
atomicMkdirAll = origMkdir
|
||||||
|
atomicCreateTemp = func(string, string) (atomicTempFile, error) { return nil, errors.New("create") }
|
||||||
|
if err := AtomicWrite(filepath.Join(t.TempDir(), "file"), nil, 0o600); err == nil || !strings.Contains(err.Error(), "create temp") {
|
||||||
|
t.Fatalf("create err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
file *atomicFakeTemp
|
||||||
|
write func(atomicTempFile) error
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"chmod", &atomicFakeTemp{chmodErr: errors.New("chmod")}, func(atomicTempFile) error { return nil }, "set permissions"},
|
||||||
|
{"write", &atomicFakeTemp{}, func(atomicTempFile) error { return errors.New("write") }, "write data"},
|
||||||
|
{"sync", &atomicFakeTemp{syncErr: errors.New("sync")}, func(atomicTempFile) error { return nil }, "sync to disk"},
|
||||||
|
{"close", &atomicFakeTemp{closeErr: errors.New("close")}, func(atomicTempFile) error { return nil }, "close temp"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
atomicCreateTemp = func(string, string) (atomicTempFile, error) { return tc.file, nil }
|
||||||
|
atomicRemove = func(string) error { return nil }
|
||||||
|
if err := atomicWrite(filepath.Join(t.TempDir(), "file"), 0o600, tc.write); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("err=%v want %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicCreateTemp = func(string, string) (atomicTempFile, error) { return &atomicFakeTemp{}, nil }
|
||||||
|
atomicRename = func(string, string) error { return errors.New("rename") }
|
||||||
|
if err := atomicWrite(filepath.Join(t.TempDir(), "file"), 0o600, func(atomicTempFile) error { return nil }); err == nil || !strings.Contains(err.Error(), "rename to final") {
|
||||||
|
t.Fatalf("rename err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicCreateTemp, atomicRename, atomicRemove = origCreate, origRename, origRemove
|
||||||
|
if n, err := AtomicWriteFromReader(filepath.Join(t.TempDir(), "file"), atomicErrorReader{}, 0o600); err == nil || n != 0 {
|
||||||
|
t.Fatalf("reader failure n=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the io.Reader contract visible in this coverage-focused fake.
|
||||||
|
var _ io.Reader = atomicErrorReader{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalendarOptionalFlagsRemainingCoverage(t *testing.T) {
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
start := "2030-01-01T09:00:00+08:00"
|
||||||
|
end := "2030-01-01T10:00:00+08:00"
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(),
|
||||||
|
"event", "create", "--title", "meeting", "--start", start, "--end", end,
|
||||||
|
"--rich-text-desc", "rich", "--location", "room", "--free-busy", "busy",
|
||||||
|
"--calendar-id", "calendar", "--remind-minutes", "5,10",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("event create options: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "event", "respond", "--id", "event", "--status", "accepted", "--calendar-id", "calendar"); err != nil {
|
||||||
|
t.Fatalf("event response calendar: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "list", "--event", "event", "--calendar-id", "calendar"); err != nil {
|
||||||
|
t.Fatalf("attendee list calendar: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "add", "--event", "event"); err == nil {
|
||||||
|
t.Fatal("attendee add without users returned nil")
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "add", "--event", "event", "--attendees", "u1", "--calendar-id", "calendar"); err != nil {
|
||||||
|
t.Fatalf("attendee add calendar: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "delete", "--event", "event"); err == nil {
|
||||||
|
t.Fatal("attendee delete without users returned nil")
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "delete", "--event", "event", "--attendees", "u1", "--calendar-id", "calendar"); err != nil {
|
||||||
|
t.Fatalf("attendee delete calendar: %v", err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "busy", "search", "--users", "u1", "--start", end, "--end", start); err == nil {
|
||||||
|
t.Fatal("reversed busy range returned nil")
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newCalendarCommand(), "attachment", "add", "--event", "event", "--files", "file:report.pdf", "--calendar-id", "calendar"); err != nil {
|
||||||
|
t.Fatalf("attachment calendar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) {
|
||||||
|
root := &cobra.Command{Use: "calendar"}
|
||||||
|
group := &cobra.Command{Use: "room"}
|
||||||
|
group.SuggestionsMinimumDistance = 3
|
||||||
|
group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}})
|
||||||
|
root.AddCommand(group)
|
||||||
|
installUnknownVerbFallback(group)
|
||||||
|
oldArgs := os.Args
|
||||||
|
os.Args = []string{"dws", "calendar", "room", "--unknown"}
|
||||||
|
t.Cleanup(func() { os.Args = oldArgs })
|
||||||
|
if err := group.RunE(group, nil); err != nil {
|
||||||
|
t.Fatalf("unknown flag fallback: %v", err)
|
||||||
|
}
|
||||||
|
printUnknownSubcmdError(group, "serach")
|
||||||
|
}
|
||||||
@@ -331,10 +331,7 @@ func TestChatFileUtilityCoverage(t *testing.T) {
|
|||||||
deps.Out.w = io.Discard
|
deps.Out.w = io.Discard
|
||||||
deps.Out.errW = io.Discard
|
deps.Out.errW = io.Discard
|
||||||
t.Cleanup(func() { deps = previous })
|
t.Cleanup(func() { deps = previous })
|
||||||
_ = printMCPText(`{"ok":true}`)
|
|
||||||
_ = printMCPText(`not-json`)
|
|
||||||
caller.format = "raw"
|
caller.format = "raw"
|
||||||
_ = printMCPText(`{"ok":true}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUploadConversationLocalFileCoverage(t *testing.T) {
|
func TestUploadConversationLocalFileCoverage(t *testing.T) {
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
chatMediaResolveAppToken = mediaResolveAppToken
|
||||||
|
chatMediaUploadFile = mediaUploadFile
|
||||||
|
mediaCreateFormFile = func(w *multipart.Writer, field, name string) (io.Writer, error) { return w.CreateFormFile(field, name) }
|
||||||
|
mediaOpenFile = os.Open
|
||||||
|
mediaCopyFile = io.Copy
|
||||||
|
)
|
||||||
|
|
||||||
func newChatMediaGroup() *cobra.Command {
|
func newChatMediaGroup() *cobra.Command {
|
||||||
media := &cobra.Command{
|
media := &cobra.Command{
|
||||||
Use: "media",
|
Use: "media",
|
||||||
@@ -72,12 +80,12 @@ func newChatMediaUploadCommand() *cobra.Command {
|
|||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
token, err := mediaResolveAppToken(ctx)
|
token, err := chatMediaResolveAppToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaID, err := mediaUploadFile(ctx, token, filePath, mediaType)
|
mediaID, err := chatMediaUploadFile(ctx, token, filePath, mediaType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -102,10 +110,7 @@ func mediaResolveAppToken(ctx context.Context) (string, error) {
|
|||||||
"请使用 dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET> 登录。")
|
"请使用 dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET> 登录。")
|
||||||
}
|
}
|
||||||
url := "https://oapi.dingtalk.com/gettoken?appkey=" + appKey + "&appsecret=" + appSecret
|
url := "https://oapi.dingtalk.com/gettoken?appkey=" + appKey + "&appsecret=" + appSecret
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error())
|
return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error())
|
||||||
@@ -136,27 +141,24 @@ func mediaUploadFile(ctx context.Context, token, filePath, mediaType string) (st
|
|||||||
go func() {
|
go func() {
|
||||||
defer pw.Close()
|
defer pw.Close()
|
||||||
defer writer.Close()
|
defer writer.Close()
|
||||||
part, err := writer.CreateFormFile("media", filepath.Base(filePath))
|
part, err := mediaCreateFormFile(writer, "media", filepath.Base(filePath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pw.CloseWithError(err)
|
pw.CloseWithError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f, err := os.Open(filePath)
|
f, err := mediaOpenFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pw.CloseWithError(err)
|
pw.CloseWithError(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
if _, err := io.Copy(part, f); err != nil {
|
if _, err := mediaCopyFile(part, f); err != nil {
|
||||||
pw.CloseWithError(err)
|
pw.CloseWithError(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
url := "https://oapi.dingtalk.com/media/upload?access_token=" + token + "&type=" + mediaType
|
url := "https://oapi.dingtalk.com/media/upload?access_token=" + token + "&type=" + mediaType
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
|
resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatMediaUploadCommandRemainingCoverage(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "image.png")
|
||||||
|
if err := os.WriteFile(file, []byte("image"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", t.TempDir()); err == nil {
|
||||||
|
t.Fatal("directory upload returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
origResolve, origUpload := chatMediaResolveAppToken, chatMediaUploadFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
chatMediaResolveAppToken = origResolve
|
||||||
|
chatMediaUploadFile = origUpload
|
||||||
|
})
|
||||||
|
chatMediaResolveAppToken = func(context.Context) (string, error) { return "", errors.New("token") }
|
||||||
|
if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil {
|
||||||
|
t.Fatal("token failure returned nil")
|
||||||
|
}
|
||||||
|
chatMediaResolveAppToken = func(context.Context) (string, error) { return "token", nil }
|
||||||
|
chatMediaUploadFile = func(context.Context, string, string, string) (string, error) { return "", errors.New("upload") }
|
||||||
|
if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil {
|
||||||
|
t.Fatal("upload failure returned nil")
|
||||||
|
}
|
||||||
|
var mediaType string
|
||||||
|
chatMediaUploadFile = func(_ context.Context, _, _, typ string) (string, error) {
|
||||||
|
mediaType = typ
|
||||||
|
return "media-id", nil
|
||||||
|
}
|
||||||
|
cmd := newChatMediaUploadCommand()
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
cmd.SetArgs([]string{"--file", file, "--type", " "})
|
||||||
|
if err := cmd.Execute(); err != nil || mediaType != "image" || !strings.Contains(out.String(), "media-id") {
|
||||||
|
t.Fatalf("success type=%q output=%q err=%v", mediaType, out.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaUploadMultipartWriterRemainingFailures(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "image.png")
|
||||||
|
if err := os.WriteFile(file, []byte("image"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
origTransport := http.DefaultTransport
|
||||||
|
origCreate, origOpen, origCopy := mediaCreateFormFile, mediaOpenFile, mediaCopyFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
http.DefaultTransport = origTransport
|
||||||
|
mediaCreateFormFile, mediaOpenFile, mediaCopyFile = origCreate, origOpen, origCopy
|
||||||
|
})
|
||||||
|
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
_, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"media_id":"id"}`)), Request: req}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mediaCreateFormFile = func(*multipart.Writer, string, string) (io.Writer, error) { return nil, errors.New("part") }
|
||||||
|
if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil {
|
||||||
|
t.Fatal("form part failure returned nil")
|
||||||
|
}
|
||||||
|
mediaCreateFormFile = origCreate
|
||||||
|
mediaOpenFile = func(string) (*os.File, error) { return nil, errors.New("open") }
|
||||||
|
if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil {
|
||||||
|
t.Fatal("open failure returned nil")
|
||||||
|
}
|
||||||
|
mediaOpenFile = origOpen
|
||||||
|
mediaCopyFile = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }
|
||||||
|
if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil {
|
||||||
|
t.Fatal("copy failure returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,15 +36,8 @@ func commandBoolFlag(cmd *cobra.Command, name string) bool {
|
|||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
root := cmd.Root()
|
rootFlags := cmd.Root().PersistentFlags()
|
||||||
var rootFlags *pflag.FlagSet
|
|
||||||
if root != nil {
|
|
||||||
rootFlags = root.PersistentFlags()
|
|
||||||
}
|
|
||||||
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), rootFlags} {
|
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), rootFlags} {
|
||||||
if flags == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
flag := flags.Lookup(name)
|
flag := flags.Lookup(name)
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ import (
|
|||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
approvalMkdirAll = os.MkdirAll
|
||||||
|
approvalCreateTemp = os.CreateTemp
|
||||||
|
approvalFileChmod = func(f *os.File, mode os.FileMode) error { return f.Chmod(mode) }
|
||||||
|
approvalFileWrite = func(f *os.File, data []byte) (int, error) { return f.Write(data) }
|
||||||
|
approvalFileClose = func(f *os.File) error { return f.Close() }
|
||||||
|
approvalRename = os.Rename
|
||||||
|
approvalRemove = os.Remove
|
||||||
|
approvalReadDir = os.ReadDir
|
||||||
|
approvalReadFile = os.ReadFile
|
||||||
|
)
|
||||||
|
|
||||||
// The "digital twin" confirmation gate. When someone @-mentions the bot with an
|
// The "digital twin" confirmation gate. When someone @-mentions the bot with an
|
||||||
// *action* request ("create a todo for me"), the bot does NOT execute it: it
|
// *action* request ("create a todo for me"), the bot does NOT execute it: it
|
||||||
// records an ApprovalRequest, sends an interactive card with [Approve]/[Reject]
|
// records an ApprovalRequest, sends an interactive card with [Approve]/[Reject]
|
||||||
@@ -416,7 +428,7 @@ func (g *approvalGate) persist(r *ApprovalRequest) {
|
|||||||
if dir == "" {
|
if dir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
if err := approvalMkdirAll(dir, 0o700); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批目录失败,跳过落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批目录失败,跳过落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -426,31 +438,31 @@ func (g *approvalGate) persist(r *ApprovalRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := filepath.Join(dir, sanitizeLockID(r.ID)+".json")
|
path := filepath.Join(dir, sanitizeLockID(r.ID)+".json")
|
||||||
tmp, err := os.CreateTemp(dir, "approval-*.json.tmp")
|
tmp, err := approvalCreateTemp(dir, "approval-*.json.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批临时文件失败,跳过落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批临时文件失败,跳过落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
if err := approvalFileChmod(tmp, config.FilePerm); err != nil {
|
||||||
tmp.Close()
|
_ = approvalFileClose(tmp)
|
||||||
_ = os.Remove(tmpName)
|
_ = approvalRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 设置审批文件权限失败,跳过落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 设置审批文件权限失败,跳过落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := tmp.Write(data); err != nil {
|
if _, err := approvalFileWrite(tmp, data); err != nil {
|
||||||
tmp.Close()
|
_ = approvalFileClose(tmp)
|
||||||
_ = os.Remove(tmpName)
|
_ = approvalRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 写入审批临时文件失败,跳过落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 写入审批临时文件失败,跳过落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tmp.Close(); err != nil {
|
if err := approvalFileClose(tmp); err != nil {
|
||||||
_ = os.Remove(tmpName)
|
_ = approvalRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 关闭审批临时文件失败,跳过落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 关闭审批临时文件失败,跳过落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
if err := approvalRename(tmpName, path); err != nil {
|
||||||
_ = os.Remove(tmpName)
|
_ = approvalRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 原子替换审批存档失败,跳过落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 原子替换审批存档失败,跳过落盘:%v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,7 +475,7 @@ func (g *approvalGate) loadAll() {
|
|||||||
if dir == "" {
|
if dir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := approvalReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批目录失败,按空起:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批目录失败,按空起:%v\n", err)
|
||||||
@@ -474,7 +486,7 @@ func (g *approvalGate) loadAll() {
|
|||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
raw, err := approvalReadFile(filepath.Join(dir, e.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批存档 %s 失败,跳过:%v\n", e.Name(), err)
|
fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批存档 %s 失败,跳过:%v\n", e.Name(), err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const (
|
|||||||
approvalDecisionReject = "reject"
|
approvalDecisionReject = "reject"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var approvalOwnerDecide = func(g *approvalGate, id string, approve bool, decidedBy string) (*ApprovalRequest, bool) {
|
||||||
|
return g.Decide(id, approve, decidedBy)
|
||||||
|
}
|
||||||
|
|
||||||
// approvalCardSender delivers and updates the owner-facing confirmation card.
|
// approvalCardSender delivers and updates the owner-facing confirmation card.
|
||||||
// It is an interface so the orchestrator and its tests never touch the network:
|
// It is an interface so the orchestrator and its tests never touch the network:
|
||||||
// the real sender talks to the DingTalk card API, the fake records calls.
|
// the real sender talks to the DingTalk card API, the fake records calls.
|
||||||
@@ -502,7 +506,7 @@ func (o *approvalOrchestrator) handleOwnerDecision(ctx context.Context, senderSt
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
decided, deciding := o.gate.Decide(req.ID, approve, senderStaffID)
|
decided, deciding := approvalOwnerDecide(o.gate, req.ID, approve, senderStaffID)
|
||||||
if !deciding {
|
if !deciding {
|
||||||
// Already decided (e.g. a double reply): consume the keyword, do not
|
// Already decided (e.g. a double reply): consume the keyword, do not
|
||||||
// re-execute.
|
// re-execute.
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type approvalSequenceRunner struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
errors []error
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *approvalSequenceRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var err error
|
||||||
|
if r.index < len(r.errors) {
|
||||||
|
err = r.errors[r.index]
|
||||||
|
}
|
||||||
|
r.index++
|
||||||
|
inv.Implemented = true
|
||||||
|
return executor.Result{Invocation: inv}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type approvalErrorNotifier struct{ err error }
|
||||||
|
|
||||||
|
func (n approvalErrorNotifier) sendOTOText(context.Context, []string, string) error { return n.err }
|
||||||
|
|
||||||
|
type approvalUnimplementedRunner struct{}
|
||||||
|
|
||||||
|
func (approvalUnimplementedRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||||
|
return executor.Result{Invocation: inv}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSheetAuditSinkRemainingEdges(t *testing.T) {
|
||||||
|
originalSleep := helperSleep
|
||||||
|
helperSleep = func(time.Duration) {}
|
||||||
|
defer func() { helperSleep = originalSleep }()
|
||||||
|
|
||||||
|
(&sheetAuditSink{}).record(context.Background(), nil)
|
||||||
|
req := &ApprovalRequest{
|
||||||
|
ID: "id", Summary: "summary", Requester: "requester", State: approvalExecuted,
|
||||||
|
AutoApproved: true, CreatedAt: time.Now(), DecidedAt: time.Now(), DecidedBy: "owner",
|
||||||
|
}
|
||||||
|
(&sheetAuditSink{runner: &approvalSequenceRunner{}}).record(context.Background(), req)
|
||||||
|
(&sheetAuditSink{runner: &approvalSequenceRunner{errors: []error{errors.New("THREADPOOL_BUSY"), nil}}}).record(context.Background(), req)
|
||||||
|
(&sheetAuditSink{runner: &approvalSequenceRunner{errors: []error{errors.New("permanent")}}}).record(context.Background(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalDecisionAndReplyRemainingEdges(t *testing.T) {
|
||||||
|
o := &approvalOrchestrator{ownerUserID: "owner", confirmPolicy: "remember", remembered: map[string]bool{"todo.create": false}}
|
||||||
|
if got := o.gateDecision("requester", "todo.create"); got != "reject" {
|
||||||
|
t.Fatalf("decision=%q", got)
|
||||||
|
}
|
||||||
|
if got, handled := (&approvalOrchestrator{}).handleReply(context.Background(), "r", "c", "plain", nil); handled || got != "plain" {
|
||||||
|
t.Fatalf("disabled reply=(%q,%v)", got, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
gate := newApprovalGate("")
|
||||||
|
o = newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
|
||||||
|
if got, handled := o.handleReply(context.Background(), "r", "c", "plain", nil); handled || got != "plain" {
|
||||||
|
t.Fatalf("plain action=(%q,%v)", got, handled)
|
||||||
|
}
|
||||||
|
if got, handled := o.handleReply(context.Background(), "r", "c", `before [[ACTION:todo.create]] after`, nil); handled || strings.Contains(got, "[[ACTION") {
|
||||||
|
t.Fatalf("unknown action=(%q,%v)", got, handled)
|
||||||
|
}
|
||||||
|
o.confirmPolicy = "remember"
|
||||||
|
o.remembered = map[string]bool{"todo.create": false}
|
||||||
|
if got, handled := o.handleReply(context.Background(), "r", "c", `[[ACTION:todo.create title="x"]]`, nil); handled || !strings.Contains(got, "此前已拒绝") {
|
||||||
|
t.Fatalf("remember reject=(%q,%v)", got, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := &ApprovalRequest{ID: "missing", Summary: "x", Action: plannedAction{Product: "todo", Tool: "tool"}}
|
||||||
|
o.autoApproveAndExecute(context.Background(), missing, "c", nil)
|
||||||
|
o.notifier = nil
|
||||||
|
o.notifyOwnerDeferred(context.Background(), missing, errors.New("ignored"))
|
||||||
|
|
||||||
|
o.notifier = approvalErrorNotifier{err: errors.New("notify")}
|
||||||
|
if got, handled := o.handleReplyText(context.Background(), &ApprovalRequest{ID: "id", Summary: "x"}); !handled || got != "" {
|
||||||
|
t.Fatalf("text notify error=(%q,%v)", got, handled)
|
||||||
|
}
|
||||||
|
if o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
||||||
|
t.Fatal("decision without pending was consumed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalConcurrentDecisionSeamEdge(t *testing.T) {
|
||||||
|
original := approvalOwnerDecide
|
||||||
|
defer func() { approvalOwnerDecide = original }()
|
||||||
|
gate := newApprovalGate("")
|
||||||
|
notifier := &fakeNotifier{}
|
||||||
|
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", notifier)
|
||||||
|
req := gate.Submit(ApprovalRequest{Requester: "r", Verb: "todo.create", Summary: "x"})
|
||||||
|
approvalOwnerDecide = func(*approvalGate, string, bool, string) (*ApprovalRequest, bool) { return req, false }
|
||||||
|
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
|
||||||
|
t.Fatal("already decided keyword was not consumed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalFlushAndExecuteRemainingEdges(t *testing.T) {
|
||||||
|
gate := newApprovalGate("")
|
||||||
|
notifier := &fakeNotifier{}
|
||||||
|
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", notifier)
|
||||||
|
if !o.flushDeferred(context.Background()) {
|
||||||
|
t.Fatal("empty flush was not consumed")
|
||||||
|
}
|
||||||
|
|
||||||
|
stuckRunner := &fakeRunner{fail: true}
|
||||||
|
o = newTextApprovalOrchestrator(gate, stuckRunner, "owner", notifier)
|
||||||
|
req := gate.Submit(ApprovalRequest{Requester: "r", Summary: "stuck", Action: plannedAction{Product: "todo", Tool: "tool"}})
|
||||||
|
gate.Decide(req.ID, true, "owner")
|
||||||
|
gate.markDeferred(req.ID, "old")
|
||||||
|
if !o.flushDeferred(context.Background()) {
|
||||||
|
t.Fatal("stuck flush was not consumed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := (&approvalOrchestrator{}).execute(context.Background(), req); err == nil {
|
||||||
|
t.Fatal("nil runner returned nil")
|
||||||
|
}
|
||||||
|
o.runner = approvalUnimplementedRunner{}
|
||||||
|
if got, err := o.execute(context.Background(), req); err != nil || !strings.Contains(got, "未实际执行") {
|
||||||
|
t.Fatalf("unimplemented=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalCardFlowRemainingEdges(t *testing.T) {
|
||||||
|
reply := func(context.Context, string, string) error { return nil }
|
||||||
|
req := ApprovalRequest{Requester: "r", Summary: "x", Action: plannedAction{Product: "todo", Tool: "tool"}}
|
||||||
|
|
||||||
|
gate := newApprovalGate("")
|
||||||
|
pending := gate.Submit(req)
|
||||||
|
o := newApprovalOrchestrator(gate, &fakeCardSender{failSend: true}, &fakeRunner{}, "owner")
|
||||||
|
if got, handled := o.handleReplyCard(context.Background(), pending, "c", reply); handled || !strings.Contains(got, "发送失败") {
|
||||||
|
t.Fatalf("send fail=(%q,%v)", got, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
gate = newApprovalGate("")
|
||||||
|
pending = gate.Submit(req)
|
||||||
|
o = newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner")
|
||||||
|
o.awaitWindow = time.Nanosecond
|
||||||
|
if _, handled := o.handleReplyCard(context.Background(), pending, "c", reply); !handled {
|
||||||
|
t.Fatal("timeout was not handled")
|
||||||
|
}
|
||||||
|
|
||||||
|
gate = newApprovalGate("")
|
||||||
|
pending = gate.Submit(req)
|
||||||
|
gate.Decide(pending.ID, true, "owner")
|
||||||
|
o = newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{fail: true}, "owner")
|
||||||
|
if _, handled := o.handleReplyCard(context.Background(), pending, "c", reply); !handled {
|
||||||
|
t.Fatal("execution failure was not handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalCardCallbackRemainingEdges(t *testing.T) {
|
||||||
|
var nilOrchestrator *approvalOrchestrator
|
||||||
|
if _, err := nilOrchestrator.handleCardCallback(context.Background(), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gate := newApprovalGate("")
|
||||||
|
o := newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner")
|
||||||
|
if _, err := o.handleCardCallback(context.Background(), &card.CardRequest{OutTrackId: "malformed"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
noAssociation := cardClickReq("missing", "", approvalDecisionApprove, approvalActionApprove, "owner")
|
||||||
|
if _, err := o.handleCardCallback(context.Background(), noAssociation); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := gate.Submit(ApprovalRequest{Summary: "x"})
|
||||||
|
gate.Decide(req.ID, false, "owner")
|
||||||
|
duplicate := cardClickReq("", req.ID, approvalDecisionReject, approvalActionReject, "owner")
|
||||||
|
if response, err := o.handleCardCallback(context.Background(), duplicate); err != nil || response.UserPrivateData.CardParamMap["dwsDecision"] != "拒绝" {
|
||||||
|
t.Fatalf("duplicate response=%+v err=%v", response, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackReject := &card.CardRequest{CardActionData: card.PrivateCardActionData{CardPrivateData: card.CardPrivateData{ActionIdList: []string{approvalActionReject}, Params: map[string]any{approvalParamID: "id"}}}}
|
||||||
|
if id, approve, ok := decodeCardAction(fallbackReject); !ok || approve || id != "id" {
|
||||||
|
t.Fatalf("fallback reject=(%q,%v,%v)", id, approve, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDingTalkApprovalCardDeliveryFailureEdge(t *testing.T) {
|
||||||
|
recorder, server := newCardAPIServer(t)
|
||||||
|
withCardAPIBase(t, server.URL)
|
||||||
|
recorder.fail["POST /v1.0/card/instances/deliver"] = 500
|
||||||
|
sender := newDingtalkApprovalCardSender("client", "secret", "template")
|
||||||
|
if _, err := sender.SendApprovalCard(context.Background(), "owner", &ApprovalRequest{ID: "id", Summary: "x"}); err == nil {
|
||||||
|
t.Fatal("delivery failure returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func installApprovalIODefaults(t *testing.T) func() {
|
||||||
|
t.Helper()
|
||||||
|
originalMkdirAll := approvalMkdirAll
|
||||||
|
originalCreateTemp := approvalCreateTemp
|
||||||
|
originalChmod := approvalFileChmod
|
||||||
|
originalWrite := approvalFileWrite
|
||||||
|
originalClose := approvalFileClose
|
||||||
|
originalRename := approvalRename
|
||||||
|
originalRemove := approvalRemove
|
||||||
|
originalReadDir := approvalReadDir
|
||||||
|
originalReadFile := approvalReadFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
approvalMkdirAll = originalMkdirAll
|
||||||
|
approvalCreateTemp = originalCreateTemp
|
||||||
|
approvalFileChmod = originalChmod
|
||||||
|
approvalFileWrite = originalWrite
|
||||||
|
approvalFileClose = originalClose
|
||||||
|
approvalRename = originalRename
|
||||||
|
approvalRemove = originalRemove
|
||||||
|
approvalReadDir = originalReadDir
|
||||||
|
approvalReadFile = originalReadFile
|
||||||
|
})
|
||||||
|
return func() {
|
||||||
|
approvalMkdirAll = os.MkdirAll
|
||||||
|
approvalCreateTemp = os.CreateTemp
|
||||||
|
approvalFileChmod = func(f *os.File, mode os.FileMode) error { return f.Chmod(mode) }
|
||||||
|
approvalFileWrite = func(f *os.File, data []byte) (int, error) { return f.Write(data) }
|
||||||
|
approvalFileClose = func(f *os.File) error { return f.Close() }
|
||||||
|
approvalRename = os.Rename
|
||||||
|
approvalRemove = os.Remove
|
||||||
|
approvalReadDir = os.ReadDir
|
||||||
|
approvalReadFile = os.ReadFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalGateLookupMutationAndAwaitEdges(t *testing.T) {
|
||||||
|
g := newApprovalGate("")
|
||||||
|
g.setOutTrackID("missing", "track")
|
||||||
|
if got := g.findByOutTrackID(" "); got != nil {
|
||||||
|
t.Fatalf("blank track=%v", got)
|
||||||
|
}
|
||||||
|
if got := g.pendingForConv(" "); got != nil {
|
||||||
|
t.Fatalf("blank conversation=%v", got)
|
||||||
|
}
|
||||||
|
if got := g.latestPending(); got != nil {
|
||||||
|
t.Fatalf("empty latest=%v", got)
|
||||||
|
}
|
||||||
|
if got := g.Get("missing"); got != nil {
|
||||||
|
t.Fatalf("missing get=%v", got)
|
||||||
|
}
|
||||||
|
if got, ok := g.Await(context.Background(), "missing", time.Millisecond); ok || got != nil {
|
||||||
|
t.Fatalf("missing await=(%v,%v)", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := g.Submit(ApprovalRequest{ID: "first", CreatedAt: time.Unix(1, 0)})
|
||||||
|
g.Decide(first.ID, true, "owner")
|
||||||
|
if got, ok := g.Await(context.Background(), first.ID, 0); !ok || got == nil {
|
||||||
|
t.Fatalf("decided await=(%v,%v)", got, ok)
|
||||||
|
}
|
||||||
|
g.markFailed(first.ID, strings.Repeat("failure", 100))
|
||||||
|
if got := g.Get(first.ID); got.State != approvalFailed || got.ExecErr == "" {
|
||||||
|
t.Fatalf("failed state=%+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := g.Submit(ApprovalRequest{ID: "pending"})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if got, ok := g.Await(ctx, pending.ID, 0); ok || got == nil {
|
||||||
|
t.Fatalf("cancelled await=(%v,%v)", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
older := g.Submit(ApprovalRequest{ID: "older", CreatedAt: time.Unix(2, 0)})
|
||||||
|
newer := g.Submit(ApprovalRequest{ID: "newer", CreatedAt: time.Unix(3, 0)})
|
||||||
|
g.Decide(older.ID, true, "owner")
|
||||||
|
g.Decide(newer.ID, true, "owner")
|
||||||
|
g.markDeferred(newer.ID, "x")
|
||||||
|
g.markDeferred(older.ID, "x")
|
||||||
|
deferred := g.allDeferred()
|
||||||
|
if len(deferred) != 2 || deferred[0].ID != "older" {
|
||||||
|
t.Fatalf("deferred order=%v", deferred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalGatePersistFailureEdges(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
defaults := installApprovalIODefaults(t)
|
||||||
|
g := &approvalGate{clientID: "io"}
|
||||||
|
req := &ApprovalRequest{ID: "id", CreatedAt: time.Now()}
|
||||||
|
boom := errors.New("io")
|
||||||
|
|
||||||
|
approvalMkdirAll = func(string, os.FileMode) error { return boom }
|
||||||
|
g.persist(req)
|
||||||
|
defaults()
|
||||||
|
|
||||||
|
bad := *req
|
||||||
|
bad.ID = "marshal"
|
||||||
|
bad.Action.Params = map[string]any{"bad": make(chan int)}
|
||||||
|
g.persist(&bad)
|
||||||
|
|
||||||
|
approvalCreateTemp = func(string, string) (*os.File, error) { return nil, boom }
|
||||||
|
g.persist(req)
|
||||||
|
defaults()
|
||||||
|
|
||||||
|
approvalFileChmod = func(*os.File, os.FileMode) error { return boom }
|
||||||
|
g.persist(req)
|
||||||
|
defaults()
|
||||||
|
|
||||||
|
approvalFileWrite = func(*os.File, []byte) (int, error) { return 0, boom }
|
||||||
|
g.persist(req)
|
||||||
|
defaults()
|
||||||
|
|
||||||
|
approvalFileClose = func(f *os.File) error { _ = f.Close(); return boom }
|
||||||
|
g.persist(req)
|
||||||
|
defaults()
|
||||||
|
|
||||||
|
approvalRename = func(string, string) error { return boom }
|
||||||
|
g.persist(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalGateLoadRemainingEdges(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
defaults := installApprovalIODefaults(t)
|
||||||
|
g := &approvalGate{clientID: "load", reqs: make(map[string]*ApprovalRequest), waiters: make(map[string]chan struct{})}
|
||||||
|
boom := errors.New("read dir")
|
||||||
|
approvalReadDir = func(string) ([]os.DirEntry, error) { return nil, boom }
|
||||||
|
g.loadAll()
|
||||||
|
defaults()
|
||||||
|
|
||||||
|
dir := g.approvalDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := map[string]string{
|
||||||
|
"note.txt": "ignored",
|
||||||
|
"corrupt.json": "{",
|
||||||
|
"blank.json": `{"id":" "}`,
|
||||||
|
"read-error.json": `{"id":"unread"}`,
|
||||||
|
"valid.json": `{"id":"valid","state":"pending"}`,
|
||||||
|
}
|
||||||
|
for name, body := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
approvalReadFile = func(path string) ([]byte, error) {
|
||||||
|
if strings.HasSuffix(path, "read-error.json") {
|
||||||
|
return nil, errors.New("read")
|
||||||
|
}
|
||||||
|
return os.ReadFile(path)
|
||||||
|
}
|
||||||
|
g.loadAll()
|
||||||
|
if g.Get("valid") == nil || g.Get("unread") != nil {
|
||||||
|
t.Fatalf("loaded=%v", g.reqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalMarkerAndDueRemainingEdges(t *testing.T) {
|
||||||
|
act, cleaned, found := parseActionMarker(`before [[ACTION:todo.create title='single quoted']] after`)
|
||||||
|
if !found || act.Args["title"] != "single quoted" || strings.Contains(cleaned, "ACTION") {
|
||||||
|
t.Fatalf("action=%+v cleaned=%q found=%v", act, cleaned, found)
|
||||||
|
}
|
||||||
|
if _, err := parseDueToMillis("not-a-date"); err == nil {
|
||||||
|
t.Fatal("invalid due returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,15 @@ const atPollInterval = 5 * time.Second
|
|||||||
// atPollWindow is how far back we look on each poll tick.
|
// atPollWindow is how far back we look on each poll tick.
|
||||||
const atPollWindow = 30 * time.Second
|
const atPollWindow = 30 * time.Second
|
||||||
|
|
||||||
|
var (
|
||||||
|
atPollExecutable = os.Executable
|
||||||
|
atPollCommandContext = exec.CommandContext
|
||||||
|
atPollTicker = func(interval time.Duration) (<-chan time.Time, func()) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
return ticker.C, ticker.Stop
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// atMentionPoller periodically queries the "search_at_me_message" API
|
// atMentionPoller periodically queries the "search_at_me_message" API
|
||||||
// using the logged-in user's token and feeds new messages into the
|
// using the logged-in user's token and feeds new messages into the
|
||||||
// connect forwarder — supplementing the Stream callback which DingTalk
|
// connect forwarder — supplementing the Stream callback which DingTalk
|
||||||
@@ -66,13 +75,13 @@ func (p *atMentionPoller) start(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 已启动 @消息轮询(间隔 %s)\n", atPollInterval)
|
fmt.Fprintf(os.Stderr, "[connect][at-poll] 已启动 @消息轮询(间隔 %s)\n", atPollInterval)
|
||||||
ticker := time.NewTicker(atPollInterval)
|
ticks, stopTicker := atPollTicker(atPollInterval)
|
||||||
defer ticker.Stop()
|
defer stopTicker()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticks:
|
||||||
p.poll(ctx)
|
p.poll(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +89,7 @@ func (p *atMentionPoller) start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *atMentionPoller) poll(ctx context.Context) {
|
func (p *atMentionPoller) poll(ctx context.Context) {
|
||||||
exe, err := os.Executable()
|
exe, err := atPollExecutable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,7 +97,7 @@ func (p *atMentionPoller) poll(ctx context.Context) {
|
|||||||
start := now.Add(-atPollWindow).Format("2006-01-02T15:04:05+08:00")
|
start := now.Add(-atPollWindow).Format("2006-01-02T15:04:05+08:00")
|
||||||
end := now.Format("2006-01-02T15:04:05+08:00")
|
end := now.Format("2006-01-02T15:04:05+08:00")
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, exe, "chat", "message", "list-mentions",
|
cmd := atPollCommandContext(ctx, exe, "chat", "message", "list-mentions",
|
||||||
"--start", start,
|
"--start", start,
|
||||||
"--end", end,
|
"--end", end,
|
||||||
"--limit", "50",
|
"--limit", "50",
|
||||||
@@ -255,20 +264,3 @@ func extractAtPollText(content, contentType string) string {
|
|||||||
}
|
}
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep-alive references: this poller was introduced alongside the connect
|
|
||||||
// daemon work but is pending wiring into the daemon startup path. Remove
|
|
||||||
// this block once atMentionPoller is instantiated by the daemon (or delete
|
|
||||||
// the file if the feature is abandoned).
|
|
||||||
var _ = func() any {
|
|
||||||
_ = atPollInterval
|
|
||||||
_ = atPollWindow
|
|
||||||
_ = atMentionMessage{}
|
|
||||||
p := (*atMentionPoller)(nil)
|
|
||||||
_ = p.start
|
|
||||||
_ = p.poll
|
|
||||||
_ = p.handleMessage
|
|
||||||
_ = p.sendGroupReply
|
|
||||||
_ = extractAtPollText
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type atPollSignalForwarder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
reply string
|
||||||
|
err error
|
||||||
|
delay time.Duration
|
||||||
|
prompts []string
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *atPollSignalForwarder) forward(_ context.Context, _ string, text string) (string, error) {
|
||||||
|
if f.delay > 0 {
|
||||||
|
time.Sleep(f.delay)
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.prompts = append(f.prompts, text)
|
||||||
|
f.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case f.done <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return f.reply, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*atPollSignalForwarder) label() string { return "poll-test" }
|
||||||
|
|
||||||
|
type atPollStreamingForwarder struct{ *atPollSignalForwarder }
|
||||||
|
|
||||||
|
func (*atPollStreamingForwarder) canStream() bool { return true }
|
||||||
|
func (f *atPollStreamingForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
|
||||||
|
return f.forward(ctx, convID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitAtPollForward(t *testing.T, f *atPollSignalForwarder) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for at-poll forward")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAtPollForTest(fwd forwarder, extras *connectExtras) *atMentionPoller {
|
||||||
|
return &atMentionPoller{
|
||||||
|
clientID: "client", botClient: newAICardClient("client", "secret", ""),
|
||||||
|
fwd: fwd, queue: newConvQueue(), dedup: newMsgDedup(20),
|
||||||
|
health: nil, extras: extras, channel: "test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtMentionPollerStartAndPollCoverage(t *testing.T) {
|
||||||
|
origAfter := helperAfter
|
||||||
|
origExecutable := atPollExecutable
|
||||||
|
origCommand := atPollCommandContext
|
||||||
|
origTicker := atPollTicker
|
||||||
|
ticks, stopTicker := origTicker(time.Hour)
|
||||||
|
_ = ticks
|
||||||
|
stopTicker()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
helperAfter = origAfter
|
||||||
|
atPollExecutable = origExecutable
|
||||||
|
atPollCommandContext = origCommand
|
||||||
|
atPollTicker = origTicker
|
||||||
|
})
|
||||||
|
helperAfter = func(time.Duration) <-chan time.Time {
|
||||||
|
ch := make(chan time.Time, 1)
|
||||||
|
ch <- time.Now()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
atPollTicker = func(time.Duration) (<-chan time.Time, func()) {
|
||||||
|
ch := make(chan time.Time, 1)
|
||||||
|
ch <- time.Now()
|
||||||
|
return ch, func() {}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
p := newAtPollForTest(&atPollSignalForwarder{done: make(chan struct{}, 1)}, &connectExtras{})
|
||||||
|
p.start(ctx)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
atPollExecutable = func() (string, error) { return "", errors.New("executable") }
|
||||||
|
p.poll(context.Background())
|
||||||
|
atPollExecutable = func() (string, error) { return "test", nil }
|
||||||
|
atPollCommandContext = func(context.Context, string, ...string) *exec.Cmd {
|
||||||
|
return exec.Command("sh", "-c", "exit 3")
|
||||||
|
}
|
||||||
|
p.poll(context.Background())
|
||||||
|
|
||||||
|
outputs := []string{
|
||||||
|
`{`,
|
||||||
|
`{"success":false}`,
|
||||||
|
`{"success":true,"result":{"items":[{"msgId":""},{"msgId":"dup","conversationType":"2"},{"msgId":"dup","conversationType":"2"},{"msgId":"other","conversationType":"1"},{"msgId":"valid","conversationType":"2","content":""}]}}`,
|
||||||
|
}
|
||||||
|
p.dedup.first("dup")
|
||||||
|
for _, output := range outputs {
|
||||||
|
value := output
|
||||||
|
atPollCommandContext = func(context.Context, string, ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command("sh", "-c", "printf %s \"$AT_POLL_OUTPUT\"")
|
||||||
|
cmd.Env = append(cmd.Environ(), "AT_POLL_OUTPUT="+value)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
p.poll(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtMentionPollerHandleCoverage(t *testing.T) {
|
||||||
|
base := atMentionMessage{MsgID: "m", OpenConversationID: "conv", SenderStaffID: "user", SenderNick: "nick", Content: `{"text":"hello"}`, ConversationType: "2"}
|
||||||
|
|
||||||
|
p := newAtPollForTest(&atPollSignalForwarder{done: make(chan struct{}, 1)}, &connectExtras{})
|
||||||
|
empty := base
|
||||||
|
empty.Content = ""
|
||||||
|
p.handleMessage(context.Background(), empty)
|
||||||
|
|
||||||
|
denied := &atPollSignalForwarder{done: make(chan struct{}, 1)}
|
||||||
|
p = newAtPollForTest(denied, &connectExtras{gate: newConnectGate([]string{"other"}, nil, 0)})
|
||||||
|
p.handleMessage(context.Background(), base)
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
select {
|
||||||
|
case <-denied.done:
|
||||||
|
t.Fatal("denied message reached forwarder")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := &atPollSignalForwarder{done: make(chan struct{}, 2)}
|
||||||
|
kb := &knowledgeBase{chunks: []knowledgeChunk{{text: "hello handbook", terms: map[string]int{"hello": 1}}}}
|
||||||
|
p = newAtPollForTest(plain, &connectExtras{kb: kb, persona: " persona "})
|
||||||
|
fallback := base
|
||||||
|
fallback.SenderNick = ""
|
||||||
|
fallback.OpenConversationID = ""
|
||||||
|
p.handleMessage(context.Background(), fallback)
|
||||||
|
waitAtPollForward(t, plain)
|
||||||
|
|
||||||
|
failing := &atPollSignalForwarder{err: errors.New("agent failed"), done: make(chan struct{}, 1)}
|
||||||
|
p = newAtPollForTest(failing, &connectExtras{})
|
||||||
|
p.handleMessage(context.Background(), fallback)
|
||||||
|
waitAtPollForward(t, failing)
|
||||||
|
|
||||||
|
origBase := dingtalkCardAPIBase
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1.0/oauth2/accessToken" {
|
||||||
|
_, _ = io.WriteString(w, `{"accessToken":"token","expireIn":7200}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "send failed", http.StatusBadRequest)
|
||||||
|
}))
|
||||||
|
dingtalkCardAPIBase = server.URL
|
||||||
|
t.Cleanup(func() {
|
||||||
|
dingtalkCardAPIBase = origBase
|
||||||
|
server.Close()
|
||||||
|
})
|
||||||
|
streamBase := &atPollSignalForwarder{reply: "reply", done: make(chan struct{}, 2)}
|
||||||
|
stream := &atPollStreamingForwarder{atPollSignalForwarder: streamBase}
|
||||||
|
p = newAtPollForTest(stream, &connectExtras{})
|
||||||
|
p.handleMessage(context.Background(), base)
|
||||||
|
waitAtPollForward(t, streamBase)
|
||||||
|
|
||||||
|
slow := &atPollSignalForwarder{delay: 30 * time.Millisecond, done: make(chan struct{}, 4)}
|
||||||
|
p = newAtPollForTest(slow, &connectExtras{})
|
||||||
|
p.handleMessage(context.Background(), base)
|
||||||
|
second := base
|
||||||
|
second.MsgID = "m2"
|
||||||
|
second.Content = `{"text":"second"}`
|
||||||
|
third := base
|
||||||
|
third.MsgID = "m3"
|
||||||
|
third.Content = `{"text":"third"}`
|
||||||
|
p.handleMessage(context.Background(), second)
|
||||||
|
p.handleMessage(context.Background(), third)
|
||||||
|
waitAtPollForward(t, slow)
|
||||||
|
waitAtPollForward(t, slow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtMentionPollerSendGroupReplyCoverage(t *testing.T) {
|
||||||
|
origBase := dingtalkCardAPIBase
|
||||||
|
t.Cleanup(func() { dingtalkCardAPIBase = origBase })
|
||||||
|
p := newAtPollForTest(&atPollSignalForwarder{done: make(chan struct{}, 1)}, &connectExtras{})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1.0/oauth2/accessToken":
|
||||||
|
_, _ = io.WriteString(w, `{"accessToken":"token","expireIn":7200}`)
|
||||||
|
case "/v1.0/robot/groupMessages/send":
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
dingtalkCardAPIBase = server.URL
|
||||||
|
if err := p.sendGroupReply(context.Background(), "conv", "reply"); err != nil {
|
||||||
|
t.Fatalf("send success: %v", err)
|
||||||
|
}
|
||||||
|
server.Close()
|
||||||
|
if err := p.sendGroupReply(context.Background(), "conv", "reply"); err == nil {
|
||||||
|
t.Fatal("transport error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.botClient.token = "token"
|
||||||
|
p.botClient.tokenExp = time.Now().Add(time.Hour)
|
||||||
|
dingtalkCardAPIBase = "%"
|
||||||
|
if err := p.sendGroupReply(context.Background(), "conv", "reply"); err == nil {
|
||||||
|
t.Fatal("invalid request URL returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.botClient.token = ""
|
||||||
|
p.botClient.tokenExp = time.Time{}
|
||||||
|
if err := p.sendGroupReply(context.Background(), "conv", "reply"); err == nil {
|
||||||
|
t.Fatal("token request error returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,13 +119,10 @@ func (c *aiCardClient) accessToken(ctx context.Context) (string, error) {
|
|||||||
if c.token != "" && time.Now().Before(c.tokenExp.Add(-5*time.Minute)) {
|
if c.token != "" && time.Now().Before(c.tokenExp.Add(-5*time.Minute)) {
|
||||||
return c.token, nil
|
return c.token, nil
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"appKey": c.clientID,
|
"appKey": c.clientID,
|
||||||
"appSecret": c.clientSecret,
|
"appSecret": c.clientSecret,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
dingtalkCardAPIBase+"/v1.0/oauth2/accessToken", bytes.NewReader(body))
|
dingtalkCardAPIBase+"/v1.0/oauth2/accessToken", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,10 +208,7 @@ func (c *aiCardClient) sendOTOText(ctx context.Context, userIDs []string, text s
|
|||||||
if len(userIDs) == 0 {
|
if len(userIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
param, err := json.Marshal(map[string]string{"content": text})
|
param, _ := json.Marshal(map[string]string{"content": text})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.call(ctx, http.MethodPost, "/v1.0/robot/oToMessages/batchSend", map[string]any{
|
return c.call(ctx, http.MethodPost, "/v1.0/robot/oToMessages/batchSend", map[string]any{
|
||||||
"robotCode": c.clientID,
|
"robotCode": c.clientID,
|
||||||
"userIds": userIDs,
|
"userIds": userIDs,
|
||||||
@@ -304,6 +298,8 @@ func (c *aiCardClient) streamingUpdate(ctx context.Context, card *aiCardInstance
|
|||||||
// a short gap lets the client subscribe before the closing frame lands.
|
// a short gap lets the client subscribe before the closing frame lands.
|
||||||
var aiCardFrameGap = 500 * time.Millisecond
|
var aiCardFrameGap = 500 * time.Millisecond
|
||||||
|
|
||||||
|
var aiCardSleepCtx = sleepCtx
|
||||||
|
|
||||||
// cardContentParams is the cardParamMap contract of the openclaw template.
|
// cardContentParams is the cardParamMap contract of the openclaw template.
|
||||||
func cardContentParams(flowStatus, content string) map[string]any {
|
func cardContentParams(flowStatus, content string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -365,7 +361,7 @@ func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content
|
|||||||
if err := c.streamFrame(ctx, card, content, true); err != nil {
|
if err := c.streamFrame(ctx, card, content, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := sleepCtx(ctx, aiCardFrameGap); err != nil {
|
if err := aiCardSleepCtx(ctx, aiCardFrameGap); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.setFlowStatus(ctx, card, aiCardFlowFinished, normalized, true)
|
return c.setFlowStatus(ctx, card, aiCardFlowFinished, normalized, true)
|
||||||
@@ -374,7 +370,7 @@ func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content
|
|||||||
// finalize is the one-shot path for channels without incremental output:
|
// finalize is the one-shot path for channels without incremental output:
|
||||||
// INPUTING → final frame → FINISHED in one call.
|
// INPUTING → final frame → FINISHED in one call.
|
||||||
func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, content string) error {
|
func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, content string) error {
|
||||||
if err := sleepCtx(ctx, aiCardFrameGap); err != nil {
|
if err := aiCardSleepCtx(ctx, aiCardFrameGap); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.finish(ctx, card, content)
|
return c.finish(ctx, card, content)
|
||||||
@@ -385,7 +381,7 @@ func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, conte
|
|||||||
// next; our burst of two frames has no such retry, and a missed fetch shows
|
// next; our burst of two frames has no such retry, and a missed fetch shows
|
||||||
// "内容加载失败" until a new frame arrives. Best-effort by design.
|
// "内容加载失败" until a new frame arrives. Best-effort by design.
|
||||||
func (c *aiCardClient) repair(ctx context.Context, card *aiCardInstance, content string) {
|
func (c *aiCardClient) repair(ctx context.Context, card *aiCardInstance, content string) {
|
||||||
if err := sleepCtx(ctx, aiCardRepairDelay); err != nil {
|
if err := aiCardSleepCtx(ctx, aiCardRepairDelay); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = c.setFlowStatus(ctx, card, aiCardFlowFinished, normalizeForCard(content), true)
|
_ = c.setFlowStatus(ctx, card, aiCardFlowFinished, normalizeForCard(content), true)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAICardAccessTokenRemainingEdges(t *testing.T) {
|
||||||
|
originalBase := dingtalkCardAPIBase
|
||||||
|
defer func() { dingtalkCardAPIBase = originalBase }()
|
||||||
|
|
||||||
|
client := newAICardClient("client", "secret", "template")
|
||||||
|
client.httpClient = &http.Client{Transport: mailRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("transport")
|
||||||
|
})}
|
||||||
|
dingtalkCardAPIBase = "https://api.invalid"
|
||||||
|
if _, err := client.accessToken(context.Background()); err == nil {
|
||||||
|
t.Fatal("token transport error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"http", http.StatusInternalServerError, "failure", false},
|
||||||
|
{"invalid-json", http.StatusOK, "{", false},
|
||||||
|
{"empty-token", http.StatusOK, `{}`, false},
|
||||||
|
{"default-expiry", http.StatusOK, `{"accessToken":"token","expireIn":0}`, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = w.Write([]byte(tc.body))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
dingtalkCardAPIBase = server.URL
|
||||||
|
client := newAICardClient("client", "secret", "template")
|
||||||
|
got, err := client.accessToken(context.Background())
|
||||||
|
if tc.ok && (err != nil || got != "token") {
|
||||||
|
t.Fatalf("token=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Fatal("error case returned nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAICardCallRawAndCheckedRemainingEdges(t *testing.T) {
|
||||||
|
originalBase := dingtalkCardAPIBase
|
||||||
|
defer func() { dingtalkCardAPIBase = originalBase }()
|
||||||
|
cached := func() *aiCardClient {
|
||||||
|
c := newAICardClient("client", "secret", "template")
|
||||||
|
c.token = "token"
|
||||||
|
c.tokenExp = time.Now().Add(time.Hour)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
c := cached()
|
||||||
|
if _, err := c.callRaw(context.Background(), http.MethodPost, "/path", map[string]any{"bad": make(chan int)}); err == nil {
|
||||||
|
t.Fatal("payload marshal error returned nil")
|
||||||
|
}
|
||||||
|
dingtalkCardAPIBase = "%"
|
||||||
|
if _, err := newAICardClient("client", "secret", "template").callRaw(context.Background(), http.MethodGet, "/path", nil); err == nil {
|
||||||
|
t.Fatal("access token error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := cached().callRaw(context.Background(), http.MethodGet, "/path", nil); err == nil {
|
||||||
|
t.Fatal("request construction error returned nil")
|
||||||
|
}
|
||||||
|
if err := cached().callChecked(context.Background(), http.MethodGet, "/path", nil); err == nil {
|
||||||
|
t.Fatal("checked transport error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dingtalkCardAPIBase = "https://api.invalid"
|
||||||
|
c = cached()
|
||||||
|
c.httpClient = &http.Client{Transport: mailRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("transport")
|
||||||
|
})}
|
||||||
|
if _, err := c.callRaw(context.Background(), http.MethodGet, "/path", nil); err == nil {
|
||||||
|
t.Fatal("call transport error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"bad"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
dingtalkCardAPIBase = server.URL
|
||||||
|
if err := cached().callChecked(context.Background(), http.MethodPost, "/deliver", nil); err == nil {
|
||||||
|
t.Fatal("business failure returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAICardDeliveryStreamingAndSleepEdges(t *testing.T) {
|
||||||
|
recorder, server := newCardAPIServer(t)
|
||||||
|
withCardAPIBase(t, server.URL)
|
||||||
|
c := newAICardClient("client", "secret", defaultAICardTemplateID)
|
||||||
|
recorder.fail["POST /v1.0/card/instances/deliver"] = 500
|
||||||
|
if _, err := c.createAndDeliver(context.Background(), groupCallback()); err == nil {
|
||||||
|
t.Fatal("deliver error returned nil")
|
||||||
|
}
|
||||||
|
recorder.fail = map[string]int{}
|
||||||
|
if _, err := c.createAndDeliver(context.Background(), &chatbot.BotCallbackDataModel{}); err == nil {
|
||||||
|
t.Fatal("one-to-one missing sender returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
long := strings.Repeat("界", aiCardMaxContent+1)
|
||||||
|
if err := c.streamingUpdate(context.Background(), &aiCardInstance{outTrackID: "track"}, long, false, false); err != nil {
|
||||||
|
t.Fatalf("long streaming update: %v", err)
|
||||||
|
}
|
||||||
|
if err := sleepCtx(context.Background(), time.Nanosecond); err != nil {
|
||||||
|
t.Fatalf("timer sleep: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalGap := aiCardFrameGap
|
||||||
|
aiCardFrameGap = time.Hour
|
||||||
|
defer func() { aiCardFrameGap = originalGap }()
|
||||||
|
cancelled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := c.finalize(cancelled, &aiCardInstance{outTrackID: "track"}, "content"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("finalize cancel=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
finishCtx := context.Background()
|
||||||
|
finishServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{}`))
|
||||||
|
}))
|
||||||
|
defer finishServer.Close()
|
||||||
|
dingtalkCardAPIBase = finishServer.URL
|
||||||
|
finishClient := newAICardClient("client", "secret", "template")
|
||||||
|
finishClient.token, finishClient.tokenExp = "token", time.Now().Add(time.Hour)
|
||||||
|
originalSleep := aiCardSleepCtx
|
||||||
|
aiCardSleepCtx = func(context.Context, time.Duration) error { return context.Canceled }
|
||||||
|
defer func() { aiCardSleepCtx = originalSleep }()
|
||||||
|
if err := finishClient.finish(finishCtx, &aiCardInstance{outTrackID: "track", inputing: true}, "content"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("finish cancel=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardMarkdownRemainingEdges(t *testing.T) {
|
||||||
|
table := "intro\n| A | B |\n|---|---|\nend"
|
||||||
|
if got := ensureCardTableBlankLines(table); !strings.Contains(got, "intro\n\n| A") {
|
||||||
|
t.Fatalf("table normalization=%q", got)
|
||||||
|
}
|
||||||
|
input := "plain\nnext\n> quote one\n> quote two\nafter\n```go\ncode\n```\n\n- item"
|
||||||
|
got := fixCardNewlines(input)
|
||||||
|
for _, want := range []string{"plain<br>next", "> quote one<br>quote two", "```go\ncode\n```", "\n- item"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("normalized output missing %q: %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ import (
|
|||||||
|
|
||||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以只读该附件或运行分析该附件所必需的只读命令,不得访问其它文件。"
|
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以只读该附件或运行分析该附件所必需的只读命令,不得访问其它文件。"
|
||||||
|
|
||||||
|
var (
|
||||||
|
codexAbsPath = filepath.Abs
|
||||||
|
codexExecCommandContext = exec.CommandContext
|
||||||
|
codexNewAppServerClient = newCodexAppServerClient
|
||||||
|
)
|
||||||
|
|
||||||
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
||||||
// keep one Codex thread per DingTalk conversation.
|
// keep one Codex thread per DingTalk conversation.
|
||||||
type codexAppServerForwarder struct {
|
type codexAppServerForwarder struct {
|
||||||
@@ -123,7 +129,7 @@ func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID,
|
|||||||
defer state.mu.Unlock()
|
defer state.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
cli, err := newCodexAppServerClient(ctx, f.bin, f.env, f.cwd())
|
cli, err := codexNewAppServerClient(ctx, f.bin, f.env, f.cwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -170,7 +176,7 @@ func (f *codexAppServerForwarder) cwd() string {
|
|||||||
if f.workDir == "" {
|
if f.workDir == "" {
|
||||||
return connectWorkDir()
|
return connectWorkDir()
|
||||||
}
|
}
|
||||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
if abs, err := codexAbsPath(f.workDir); err == nil {
|
||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
return f.workDir
|
return f.workDir
|
||||||
@@ -329,7 +335,7 @@ type codexRPCError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newCodexAppServerClient(ctx context.Context, bin string, env []string, cwd string) (*codexAppServerClient, error) {
|
func newCodexAppServerClient(ctx context.Context, bin string, env []string, cwd string) (*codexAppServerClient, error) {
|
||||||
cmd := exec.CommandContext(ctx, bin, "app-server", "--stdio")
|
cmd := codexExecCommandContext(ctx, bin, "app-server", "--stdio")
|
||||||
cmd.Dir = cwd
|
cmd.Dir = cwd
|
||||||
if len(env) > 0 {
|
if len(env) > 0 {
|
||||||
cmd.Env = append(os.Environ(), env...)
|
cmd.Env = append(os.Environ(), env...)
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type codexNthFailWriter struct {
|
||||||
|
writes int
|
||||||
|
failAt int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *codexNthFailWriter) Write(p []byte) (int, error) {
|
||||||
|
w.writes++
|
||||||
|
if w.writes >= w.failAt {
|
||||||
|
return 0, errors.New("write")
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
func (*codexNthFailWriter) Close() error { return nil }
|
||||||
|
|
||||||
|
type codexErrorReader struct{ err error }
|
||||||
|
|
||||||
|
func (r codexErrorReader) Read([]byte) (int, error) { return 0, r.err }
|
||||||
|
|
||||||
|
func codexIntPtr(v int) *int { return &v }
|
||||||
|
|
||||||
|
func unitCodexClient(stdin io.WriteCloser, messages ...codexRPCMessage) *codexAppServerClient {
|
||||||
|
msgs := make(chan codexRPCMessage, len(messages)+1)
|
||||||
|
for _, msg := range messages {
|
||||||
|
msgs <- msg
|
||||||
|
}
|
||||||
|
return &codexAppServerClient{
|
||||||
|
cmd: &exec.Cmd{}, stdin: stdin, msgs: msgs, readErr: make(chan error, 1),
|
||||||
|
done: make(chan struct{}), stderr: &lockedBuffer{}, nextID: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexForwardAppServerFailureAndResumeEdges(t *testing.T) {
|
||||||
|
originalFactory := codexNewAppServerClient
|
||||||
|
defer func() { codexNewAppServerClient = originalFactory }()
|
||||||
|
boom := errors.New("factory")
|
||||||
|
codexNewAppServerClient = func(context.Context, string, []string, string) (*codexAppServerClient, error) {
|
||||||
|
return nil, boom
|
||||||
|
}
|
||||||
|
if _, err := (&codexAppServerForwarder{bin: "codex"}).forwardAppServer(context.Background(), "conv", "text", nil, nil); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("factory error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
turnDone := json.RawMessage(`{"threadId":"new-thread","turn":{"status":"completed","items":[{"type":"agentMessage","text":"done"}]}}`)
|
||||||
|
client := unitCodexClient(&bufferWriteCloser{},
|
||||||
|
codexRPCMessage{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)},
|
||||||
|
codexRPCMessage{ID: codexIntPtr(2), Error: &codexRPCError{Message: "resume"}},
|
||||||
|
codexRPCMessage{ID: codexIntPtr(3), Result: json.RawMessage(`{"thread":{"id":"new-thread"}}`)},
|
||||||
|
codexRPCMessage{Method: "turn/completed", Params: turnDone},
|
||||||
|
)
|
||||||
|
codexNewAppServerClient = func(context.Context, string, []string, string) (*codexAppServerClient, error) { return client, nil }
|
||||||
|
sessions := newCodexThreadSessions("")
|
||||||
|
sessions.setThreadID("conv", "old-thread")
|
||||||
|
fwd := &codexAppServerForwarder{bin: "codex", sessions: sessions}
|
||||||
|
if reply, err := fwd.forwardAppServer(context.Background(), "conv", "text", nil, nil); err != nil || reply != "done" {
|
||||||
|
t.Fatalf("resume recovery reply=%q err=%v", reply, err)
|
||||||
|
}
|
||||||
|
if sessions.threadID("conv") != "new-thread" {
|
||||||
|
t.Fatalf("thread=%q", sessions.threadID("conv"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
messages []codexRPCMessage
|
||||||
|
}{
|
||||||
|
{"start", []codexRPCMessage{{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)}, {ID: codexIntPtr(2), Error: &codexRPCError{Message: "start"}}}},
|
||||||
|
{"turn", []codexRPCMessage{{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)}, {ID: codexIntPtr(2), Result: json.RawMessage(`{"thread":{"id":"thread"}}`)}, {ID: codexIntPtr(3), Error: &codexRPCError{Message: "turn"}}}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client := unitCodexClient(&bufferWriteCloser{}, tc.messages...)
|
||||||
|
codexNewAppServerClient = func(context.Context, string, []string, string) (*codexAppServerClient, error) { return client, nil }
|
||||||
|
if _, err := (&codexAppServerForwarder{bin: "codex"}).forwardAppServer(context.Background(), "conv", "text", nil, nil); err == nil {
|
||||||
|
t.Fatal("failure returned nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexCwdConstructorAndReadLoopEdges(t *testing.T) {
|
||||||
|
originalAbs := codexAbsPath
|
||||||
|
originalExec := codexExecCommandContext
|
||||||
|
defer func() {
|
||||||
|
codexAbsPath = originalAbs
|
||||||
|
codexExecCommandContext = originalExec
|
||||||
|
}()
|
||||||
|
codexAbsPath = func(string) (string, error) { return "", errors.New("abs") }
|
||||||
|
if got := (&codexAppServerForwarder{workDir: "relative"}).cwd(); got != "relative" {
|
||||||
|
t.Fatalf("cwd=%q", got)
|
||||||
|
}
|
||||||
|
codexAbsPath = filepath.Abs
|
||||||
|
|
||||||
|
cases := []func() *exec.Cmd{
|
||||||
|
func() *exec.Cmd {
|
||||||
|
cmd := exec.Command("sh", "-c", "exit 0")
|
||||||
|
cmd.Stdin = strings.NewReader("")
|
||||||
|
return cmd
|
||||||
|
},
|
||||||
|
func() *exec.Cmd { cmd := exec.Command("sh", "-c", "exit 0"); cmd.Stdout = io.Discard; return cmd },
|
||||||
|
func() *exec.Cmd { return exec.Command(filepath.Join(t.TempDir(), "missing")) },
|
||||||
|
}
|
||||||
|
for _, makeCmd := range cases {
|
||||||
|
codexExecCommandContext = func(context.Context, string, ...string) *exec.Cmd { return makeCmd() }
|
||||||
|
if client, err := newCodexAppServerClient(context.Background(), "codex", []string{"A=B"}, t.TempDir()); err == nil || client != nil {
|
||||||
|
t.Fatalf("constructor client=%v err=%v", client, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
c.readLoop(strings.NewReader("\n{\n"))
|
||||||
|
if err := <-c.readErr; err == nil || !strings.Contains(err.Error(), "parse") {
|
||||||
|
t.Fatalf("parse read error=%v", err)
|
||||||
|
}
|
||||||
|
c = unitCodexClient(&bufferWriteCloser{})
|
||||||
|
c.readLoop(codexErrorReader{err: errors.New("scan")})
|
||||||
|
if err := <-c.readErr; err == nil || !strings.Contains(err.Error(), "scan") {
|
||||||
|
t.Fatalf("scanner error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexClientSendInitializeAndRequestEdges(t *testing.T) {
|
||||||
|
if err := unitCodexClient(&codexNthFailWriter{failAt: 1}).initialize(context.Background()); err == nil {
|
||||||
|
t.Fatal("initialize first send error returned nil")
|
||||||
|
}
|
||||||
|
c := unitCodexClient(&codexNthFailWriter{failAt: 2}, codexRPCMessage{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)})
|
||||||
|
if err := c.initialize(context.Background()); err == nil {
|
||||||
|
t.Fatal("initialized notification error returned nil")
|
||||||
|
}
|
||||||
|
if err := unitCodexClient(&codexNthFailWriter{failAt: 1}).send(make(chan int)); err == nil {
|
||||||
|
t.Fatal("marshal error returned nil")
|
||||||
|
}
|
||||||
|
for _, call := range []func(*codexAppServerClient) error{
|
||||||
|
func(c *codexAppServerClient) error { _, err := c.startThread(context.Background(), nil); return err },
|
||||||
|
func(c *codexAppServerClient) error { _, err := c.resumeThread(context.Background(), nil); return err },
|
||||||
|
func(c *codexAppServerClient) error {
|
||||||
|
_, err := c.runTurn(context.Background(), "thread", "text", nil, nil)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
if err := call(unitCodexClient(&codexNthFailWriter{failAt: 1})); err == nil {
|
||||||
|
t.Fatal("request send error returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexRunTurnRemainingEdges(t *testing.T) {
|
||||||
|
serverID := 99
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
messages []codexRPCMessage
|
||||||
|
close bool
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"closed", nil, true, "", true},
|
||||||
|
{"server-request-and-turn-error", []codexRPCMessage{{ID: &serverID, Method: "request/input"}, {ID: codexIntPtr(1), Error: &codexRPCError{Message: "turn failed"}}}, false, "", true},
|
||||||
|
{"wrong-completion-then-notification", []codexRPCMessage{{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"other","turn":{}}`)}, {Method: "error", Params: json.RawMessage(`{"message":"bad"}`)}}, false, "", true},
|
||||||
|
{"failed-without-message", []codexRPCMessage{{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread","turn":{"status":"failed"}}`)}}, false, "", true},
|
||||||
|
{"delta-fallback", []codexRPCMessage{{Method: "item/agentMessage/delta", Params: json.RawMessage(`{"threadId":"thread","delta":"partial"}`)}, {Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread","turn":{"status":"completed"}}`)}}, false, "partial", false},
|
||||||
|
{"empty", []codexRPCMessage{{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread","turn":{"status":"completed"}}`)}}, false, "", true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client := unitCodexClient(&bufferWriteCloser{}, tc.messages...)
|
||||||
|
if tc.close {
|
||||||
|
close(client.msgs)
|
||||||
|
}
|
||||||
|
var delta string
|
||||||
|
got, err := client.runTurn(context.Background(), "thread", "text", nil, func(s string) { delta = s })
|
||||||
|
if (err != nil) != tc.wantErr || got != tc.want {
|
||||||
|
t.Fatalf("got=%q delta=%q err=%v", got, delta, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexWaitResponseAndNextEdges(t *testing.T) {
|
||||||
|
serverID, otherID, wantID := 99, 2, 1
|
||||||
|
c := unitCodexClient(&bufferWriteCloser{},
|
||||||
|
codexRPCMessage{ID: &serverID, Method: "request/input"},
|
||||||
|
codexRPCMessage{},
|
||||||
|
codexRPCMessage{ID: &otherID},
|
||||||
|
codexRPCMessage{ID: &wantID, Error: &codexRPCError{Message: "response"}},
|
||||||
|
)
|
||||||
|
if _, err := c.waitResponse(context.Background(), wantID); err == nil {
|
||||||
|
t.Fatal("response error returned nil")
|
||||||
|
}
|
||||||
|
c = unitCodexClient(&bufferWriteCloser{})
|
||||||
|
close(c.msgs)
|
||||||
|
if _, err := c.waitResponse(context.Background(), wantID); err == nil {
|
||||||
|
t.Fatal("closed response returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
close(closed.msgs)
|
||||||
|
if _, err := closed.next(context.Background()); err == nil {
|
||||||
|
t.Fatal("closed buffered channel returned nil")
|
||||||
|
}
|
||||||
|
delayed := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
delayed.msgs <- codexRPCMessage{Method: "delayed"}
|
||||||
|
}()
|
||||||
|
if msg, err := delayed.next(context.Background()); err != nil || msg.Method != "delayed" {
|
||||||
|
t.Fatalf("delayed msg=%+v err=%v", msg, err)
|
||||||
|
}
|
||||||
|
delayedClose := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
close(delayedClose.msgs)
|
||||||
|
}()
|
||||||
|
if _, err := delayedClose.next(context.Background()); err == nil {
|
||||||
|
t.Fatal("delayed close returned nil")
|
||||||
|
}
|
||||||
|
readFailure := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
readFailure.readErr <- errors.New("read")
|
||||||
|
if _, err := readFailure.next(context.Background()); err == nil {
|
||||||
|
t.Fatal("read error returned nil")
|
||||||
|
}
|
||||||
|
done := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
close(done.done)
|
||||||
|
if _, err := done.next(context.Background()); err == nil {
|
||||||
|
t.Fatal("done returned nil")
|
||||||
|
}
|
||||||
|
cancelled := unitCodexClient(&bufferWriteCloser{})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := cancelled.next(ctx); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancel error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexResultParsingAndDefaultKeyEdges(t *testing.T) {
|
||||||
|
if got := codexConvKey(" "); got != "_default" {
|
||||||
|
t.Fatalf("default key=%q", got)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
result json.RawMessage
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{nil, errors.New("upstream")},
|
||||||
|
{json.RawMessage(`{`), nil},
|
||||||
|
{json.RawMessage(`{}`), nil},
|
||||||
|
} {
|
||||||
|
if _, err := codexThreadIDFromResult(tc.result, tc.err); err == nil {
|
||||||
|
t.Fatalf("result=%s upstream=%v returned nil", tc.result, tc.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, _, ok := codexTurnCompletedText(json.RawMessage(`{`), "thread"); ok {
|
||||||
|
t.Fatal("invalid completed payload accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,6 @@ func TestConnectForwarderPureCoverage(t *testing.T) {
|
|||||||
streamSDKLogger{}.Errorf("error %s", "x")
|
streamSDKLogger{}.Errorf("error %s", "x")
|
||||||
streamSDKLogger{}.Fatalf("fatal %s", "x")
|
streamSDKLogger{}.Fatalf("fatal %s", "x")
|
||||||
_ = agentNotInstalled("channel", "app", "install")
|
_ = agentNotInstalled("channel", "app", "install")
|
||||||
_ = insertBeforeArg([]string{"bin", "--marker", "tail"}, "--marker", "inserted")
|
|
||||||
_ = insertBeforeArg([]string{"bin", "tail"}, "--missing", "inserted")
|
|
||||||
|
|
||||||
sessions := newConvSessions("")
|
sessions := newConvSessions("")
|
||||||
execFwd := &execForwarder{name: "agent", argv: []string{"bin"}, sessions: sessions}
|
execFwd := &execForwarder{name: "agent", argv: []string{"bin"}, sessions: sessions}
|
||||||
@@ -295,11 +293,11 @@ func TestConnectStreamRemainingPureEdges(t *testing.T) {
|
|||||||
|
|
||||||
turns := []connectQueuedTurn{
|
turns := []connectQueuedTurn{
|
||||||
{},
|
{},
|
||||||
{picCode: "picture-code"},
|
{picCodes: []string{"picture-code"}},
|
||||||
{fileInfo: fileInboundInfo{FileName: " named.txt ", DownloadCode: "download"}},
|
{fileInfos: []fileInboundInfo{{FileName: " named.txt ", DownloadCode: "download"}}},
|
||||||
{fileInfo: fileInboundInfo{DownloadCode: "download"}},
|
{fileInfos: []fileInboundInfo{{DownloadCode: "download"}}},
|
||||||
}
|
}
|
||||||
wants := []string{"[空消息]", "[图片]", "[文件: named.txt]", "[文件]"}
|
wants := []string{"[空消息]", "[图片]", "[附件: named.txt]", "[1 个附件]"}
|
||||||
for i, turn := range turns {
|
for i, turn := range turns {
|
||||||
if got := connectTurnSummary(turn); got != wants[i] {
|
if got := connectTurnSummary(turn); got != wants[i] {
|
||||||
t.Errorf("summary %d = %q, want %q", i, got, wants[i])
|
t.Errorf("summary %d = %q, want %q", i, got, wants[i])
|
||||||
@@ -313,17 +311,17 @@ func TestConnectStreamRemainingPureEdges(t *testing.T) {
|
|||||||
t.Fatalf("single merge = %#v", got)
|
t.Fatalf("single merge = %#v", got)
|
||||||
}
|
}
|
||||||
mergedPicture := mergeConnectQueuedTurns([]connectQueuedTurn{
|
mergedPicture := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||||
{picCode: "old-picture", msgID: "m1"},
|
{picCodes: []string{"old-picture"}, msgID: "m1"},
|
||||||
{text: "latest", msgID: "m2"},
|
{text: "latest", msgID: "m2"},
|
||||||
})
|
})
|
||||||
if mergedPicture.picCode != "old-picture" {
|
if len(mergedPicture.picCodes) != 1 || mergedPicture.picCodes[0] != "old-picture" {
|
||||||
t.Fatalf("merged picture = %#v", mergedPicture)
|
t.Fatalf("merged picture = %#v", mergedPicture)
|
||||||
}
|
}
|
||||||
mergedFile := mergeConnectQueuedTurns([]connectQueuedTurn{
|
mergedFile := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||||
{fileInfo: fileInboundInfo{FileName: "old", DownloadCode: "code"}, msgID: "m1"},
|
{fileInfos: []fileInboundInfo{{FileName: "old", DownloadCode: "code"}}, msgID: "m1"},
|
||||||
{text: "latest", msgID: "m2"},
|
{text: "latest", msgID: "m2"},
|
||||||
})
|
})
|
||||||
if !mergedFile.fileInfo.hasActionable() {
|
if len(mergedFile.fileInfos) != 1 || !mergedFile.fileInfos[0].hasActionable() {
|
||||||
t.Fatalf("merged file = %#v", mergedFile)
|
t.Fatalf("merged file = %#v", mergedFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var connectGetrlimit = syscall.Getrlimit
|
||||||
|
|
||||||
func checkFDLimit() {
|
func checkFDLimit() {
|
||||||
var rlim syscall.Rlimit
|
var rlim syscall.Rlimit
|
||||||
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil {
|
if err := connectGetrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rlim.Cur < 512 {
|
if rlim.Cur < 512 {
|
||||||
|
|||||||
@@ -115,14 +115,8 @@ func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ strin
|
|||||||
},
|
},
|
||||||
Contents: []geminiContent{{Role: "user", Parts: parts}},
|
Contents: []geminiContent{{Role: "user", Parts: parts}},
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(body)
|
raw, _ := json.Marshal(body)
|
||||||
if err != nil {
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||||
|
|
||||||
|
|||||||
@@ -184,10 +184,7 @@ func (h *connectHealth) flush() error {
|
|||||||
snapshot := h.hb
|
snapshot := h.hb
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
data, _ := json.MarshalIndent(snapshot, "", " ")
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
path := connectHeartbeatPath(h.dir)
|
path := connectHeartbeatPath(h.dir)
|
||||||
tmp := path + ".tmp"
|
tmp := path + ".tmp"
|
||||||
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConnectHealthRemainingIOAndListingBranches(t *testing.T) {
|
||||||
|
origOverride := connectDaemonDirOverride
|
||||||
|
t.Cleanup(func() { connectDaemonDirOverride = origOverride })
|
||||||
|
|
||||||
|
blockingFile := filepath.Join(t.TempDir(), "file")
|
||||||
|
if err := os.WriteFile(blockingFile, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
connectDaemonDirOverride = blockingFile
|
||||||
|
if h := newConnectHealth("client", "codex"); h != nil {
|
||||||
|
t.Fatalf("health with invalid base=%+v", h)
|
||||||
|
}
|
||||||
|
if _, err := listConnectors(time.Now()); err == nil {
|
||||||
|
t.Fatal("list against non-directory base returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &connectHealth{dir: filepath.Join(t.TempDir(), "missing"), hb: connectHeartbeat{UpdatedUnix: 1}}
|
||||||
|
h.onError(nil)
|
||||||
|
if err := h.flush(); err == nil {
|
||||||
|
t.Fatal("heartbeat write failure returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
renameDir := t.TempDir()
|
||||||
|
if err := os.Mkdir(connectHeartbeatPath(renameDir), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h = &connectHealth{dir: renameDir, hb: connectHeartbeat{UpdatedUnix: 1}}
|
||||||
|
if err := h.flush(); err == nil {
|
||||||
|
t.Fatal("heartbeat rename failure returned nil")
|
||||||
|
}
|
||||||
|
if _, err := readConnectHeartbeat(renameDir); err == nil {
|
||||||
|
t.Fatal("heartbeat read I/O failure returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
connectDaemonDirOverride = root
|
||||||
|
base := connectBaseDir()
|
||||||
|
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(base, "not-a-connector"), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
badDir := filepath.Join(base, "bad-heartbeat")
|
||||||
|
if err := os.MkdirAll(connectHeartbeatPath(badDir), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
daemonDir := filepath.Join(base, "daemon-only")
|
||||||
|
if err := os.MkdirAll(daemonDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeDaemonState(daemonDir, daemonState{Pid: os.Getpid(), UnifiedAppID: "app-id"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reports, err := listConnectors(time.Now())
|
||||||
|
if err != nil || len(reports) != 1 || reports[0].ClientID != "daemon-only" || reports[0].UnifiedAppID != "app-id" {
|
||||||
|
t.Fatalf("reports=%+v err=%v", reports, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dead := &connectHeartbeat{Pid: -1}
|
||||||
|
if got := deriveConnectHealth(dead, true, time.Now()); got.Detail != "connector process not alive; supervisor should restart it" {
|
||||||
|
t.Fatalf("supervised dead detail=%q", got.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectDaemonDirOverride = ""
|
||||||
|
if got := connectBaseDir(); got == "" {
|
||||||
|
t.Fatal("default connect base is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ type knowledgeBase struct {
|
|||||||
// knowledgeTextExts is the set of plain-text extensions the retriever indexes.
|
// knowledgeTextExts is the set of plain-text extensions the retriever indexes.
|
||||||
var knowledgeTextExts = []string{".md", ".markdown", ".mdx", ".txt", ".text"}
|
var knowledgeTextExts = []string{".md", ".markdown", ".mdx", ".txt", ".text"}
|
||||||
|
|
||||||
|
var knowledgeReadFile = os.ReadFile
|
||||||
|
|
||||||
// isKnowledgeTextExt reports whether ext is an indexable plain-text extension.
|
// isKnowledgeTextExt reports whether ext is an indexable plain-text extension.
|
||||||
func isKnowledgeTextExt(ext string) bool {
|
func isKnowledgeTextExt(ext string) bool {
|
||||||
ext = strings.ToLower(ext)
|
ext = strings.ToLower(ext)
|
||||||
@@ -84,7 +86,7 @@ func loadKnowledgeBase(dir string) (*knowledgeBase, error) {
|
|||||||
fmt.Fprintf(os.Stderr, "[connect][knowledge] 跳过超大文件 %s\n", path)
|
fmt.Fprintf(os.Stderr, "[connect][knowledge] 跳过超大文件 %s\n", path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
raw, rerr := os.ReadFile(path)
|
raw, rerr := knowledgeReadFile(path)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
return rerr
|
return rerr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKnowledgeLoadAndAugmentRemainingCoverage(t *testing.T) {
|
||||||
|
if _, err := loadKnowledgeBase(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||||
|
t.Fatal("missing knowledge directory returned nil")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, ".hidden"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".hidden", "secret.md"), []byte("hidden"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "visible.md"), []byte("# First\nbody\n# Second\n"+strings.Repeat("x", knowledgeMaxChunkRunes)), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
large := filepath.Join(dir, "large.txt")
|
||||||
|
f, err := os.Create(large)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Truncate(knowledgeMaxFileBytes + 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kb, err := loadKnowledgeBase(dir)
|
||||||
|
if err != nil || len(kb.chunks) < 2 {
|
||||||
|
t.Fatalf("load chunks=%d err=%v", len(kb.chunks), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origRead := knowledgeReadFile
|
||||||
|
knowledgeReadFile = func(string) ([]byte, error) { return nil, errors.New("read failed") }
|
||||||
|
if _, err := loadKnowledgeBase(dir); err == nil || !strings.Contains(err.Error(), "read failed") {
|
||||||
|
t.Fatalf("read failure err=%v", err)
|
||||||
|
}
|
||||||
|
knowledgeReadFile = origRead
|
||||||
|
t.Cleanup(func() { knowledgeReadFile = origRead })
|
||||||
|
|
||||||
|
if got := kb.augment("!"); got != "!" {
|
||||||
|
t.Fatalf("term-free question=%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
long := strings.Repeat("alpha beta ", 150)
|
||||||
|
ranked := &knowledgeBase{chunks: []knowledgeChunk{
|
||||||
|
{source: "one", text: long, terms: map[string]int{"alpha": 1, "beta": 1}},
|
||||||
|
{source: "two", text: long, terms: map[string]int{"alpha": 3}},
|
||||||
|
{source: "three", text: long, terms: map[string]int{"alpha": 2}},
|
||||||
|
{source: "four", text: long, terms: map[string]int{"alpha": 1}},
|
||||||
|
}}
|
||||||
|
got := ranked.augment("alpha beta")
|
||||||
|
if !strings.Contains(got, "用户问题:alpha beta") {
|
||||||
|
t.Fatalf("augmented prompt missing question: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,13 @@ const (
|
|||||||
knowledgeSourceDoc
|
knowledgeSourceDoc
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
wikiLoadKnowledgeBase = loadKnowledgeBase
|
||||||
|
wikiRemoveAll = os.RemoveAll
|
||||||
|
wikiMkdirAll = os.MkdirAll
|
||||||
|
wikiWriteFile = os.WriteFile
|
||||||
|
)
|
||||||
|
|
||||||
type knowledgeSource struct {
|
type knowledgeSource struct {
|
||||||
kind knowledgeSourceKind
|
kind knowledgeSourceKind
|
||||||
// ref is the directory path (dir), the spaceId (wiki) or the nodeId (doc).
|
// ref is the directory path (dir), the spaceId (wiki) or the nodeId (doc).
|
||||||
@@ -141,9 +148,6 @@ func (f runnerWikiFetcher) listNodes(ctx context.Context, spaceID string) ([]wik
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, n := range collectWikiNodes(res.Response) {
|
for _, n := range collectWikiNodes(res.Response) {
|
||||||
if n.id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, dup := seen[n.id]; dup {
|
if _, dup := seen[n.id]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -184,7 +188,7 @@ func loadWikiKnowledgeBase(ctx context.Context, fetcher wikiFetcher, src knowled
|
|||||||
docs, err := pullWikiDocs(ctx, fetcher, src)
|
docs, err := pullWikiDocs(ctx, fetcher, src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Pull failed: try the previous cache before giving up.
|
// Pull failed: try the previous cache before giving up.
|
||||||
if kb, lerr := loadKnowledgeBase(cacheDir); lerr == nil {
|
if kb, lerr := wikiLoadKnowledgeBase(cacheDir); lerr == nil {
|
||||||
fmt.Fprintf(logw, "[connect][knowledge] 拉取知识库失败(%v),使用旧缓存兜底:%s\n", err, cacheDir)
|
fmt.Fprintf(logw, "[connect][knowledge] 拉取知识库失败(%v),使用旧缓存兜底:%s\n", err, cacheDir)
|
||||||
return kb
|
return kb
|
||||||
}
|
}
|
||||||
@@ -199,7 +203,7 @@ func loadWikiKnowledgeBase(ctx context.Context, fetcher wikiFetcher, src knowled
|
|||||||
return buildKnowledgeBaseFromDocs(docs)
|
return buildKnowledgeBaseFromDocs(docs)
|
||||||
}
|
}
|
||||||
|
|
||||||
kb, lerr := loadKnowledgeBase(cacheDir)
|
kb, lerr := wikiLoadKnowledgeBase(cacheDir)
|
||||||
if lerr != nil {
|
if lerr != nil {
|
||||||
// Cache wrote but produced no usable chunks (e.g. all docs empty):
|
// Cache wrote but produced no usable chunks (e.g. all docs empty):
|
||||||
// degrade to an empty base rather than failing the connector.
|
// degrade to an empty base rather than failing the connector.
|
||||||
@@ -279,10 +283,10 @@ func buildKnowledgeBaseFromDocs(docs []wikiDoc) *knowledgeBase {
|
|||||||
// .md per document) so loadKnowledgeBase can index it like any local directory.
|
// .md per document) so loadKnowledgeBase can index it like any local directory.
|
||||||
// It clears stale files first so deleted wiki docs do not linger in answers.
|
// It clears stale files first so deleted wiki docs do not linger in answers.
|
||||||
func writeWikiCache(cacheDir string, docs []wikiDoc) error {
|
func writeWikiCache(cacheDir string, docs []wikiDoc) error {
|
||||||
if err := os.RemoveAll(cacheDir); err != nil {
|
if err := wikiRemoveAll(cacheDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
if err := wikiMkdirAll(cacheDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
used := map[string]int{}
|
used := map[string]int{}
|
||||||
@@ -299,7 +303,7 @@ func writeWikiCache(cacheDir string, docs []wikiDoc) error {
|
|||||||
used[stem] = 1
|
used[stem] = 1
|
||||||
}
|
}
|
||||||
path := filepath.Join(cacheDir, stem+".md")
|
path := filepath.Join(cacheDir, stem+".md")
|
||||||
if err := os.WriteFile(path, []byte(d.text), 0o644); err != nil {
|
if err := wikiWriteFile(path, []byte(d.text), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,21 +438,17 @@ func loadConnectKnowledgeSource(cmd *cobra.Command, runner executor.Runner, clie
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logw := cmd.ErrOrStderr()
|
logw := cmd.ErrOrStderr()
|
||||||
switch src.kind {
|
if src.kind == knowledgeSourceDir {
|
||||||
case knowledgeSourceDir:
|
|
||||||
kb, lerr := loadKnowledgeBase(src.ref)
|
kb, lerr := loadKnowledgeBase(src.ref)
|
||||||
if lerr != nil {
|
if lerr != nil {
|
||||||
return nil, lerr
|
return nil, lerr
|
||||||
}
|
}
|
||||||
fmt.Fprintf(logw, "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), src.ref)
|
fmt.Fprintf(logw, "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), src.ref)
|
||||||
return kb, nil
|
return kb, nil
|
||||||
case knowledgeSourceWiki, knowledgeSourceDoc:
|
|
||||||
fetcher := runnerWikiFetcher{runner: runner}
|
|
||||||
cacheRoot := connectKnowledgeCacheRoot(clientID)
|
|
||||||
return loadWikiKnowledgeBase(cmd.Context(), fetcher, src, cacheRoot, logw), nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported knowledge source kind %d", src.kind)
|
|
||||||
}
|
}
|
||||||
|
fetcher := runnerWikiFetcher{runner: runner}
|
||||||
|
cacheRoot := connectKnowledgeCacheRoot(clientID)
|
||||||
|
return loadWikiKnowledgeBase(cmd.Context(), fetcher, src, cacheRoot, logw), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectKnowledgeCacheRoot is the per-robot knowledge cache root:
|
// connectKnowledgeCacheRoot is the per-robot knowledge cache root:
|
||||||
@@ -456,8 +456,5 @@ func loadConnectKnowledgeSource(cmd *cobra.Command, runner executor.Runner, clie
|
|||||||
// the directory; an empty clientId degrades to a shared "default" bucket.
|
// the directory; an empty clientId degrades to a shared "default" bucket.
|
||||||
func connectKnowledgeCacheRoot(clientID string) string {
|
func connectKnowledgeCacheRoot(clientID string) string {
|
||||||
id := sanitizeWikiStem(clientID)
|
id := sanitizeWikiStem(clientID)
|
||||||
if id == "" {
|
|
||||||
id = "default"
|
|
||||||
}
|
|
||||||
return filepath.Join(homeDir(), ".dws", "connect", id, "knowledge")
|
return filepath.Join(homeDir(), ".dws", "connect", id, "knowledge")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWikiPullRemainingCoverage(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := pullWikiDocs(ctx, fakeWikiFetcher{readErrFor: map[string]error{"doc": errors.New("read")}}, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}); err == nil {
|
||||||
|
t.Fatal("single doc read error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := pullWikiDocs(ctx, fakeWikiFetcher{content: map[string]string{"doc": " "}}, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}); err == nil {
|
||||||
|
t.Fatal("empty single doc returned nil")
|
||||||
|
}
|
||||||
|
if _, err := pullWikiDocs(ctx, fakeWikiFetcher{listErr: errors.New("list")}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}); err == nil {
|
||||||
|
t.Fatal("wiki list error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := pullWikiDocs(ctx, fakeWikiFetcher{}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}); err == nil {
|
||||||
|
t.Fatal("empty wiki node list returned nil")
|
||||||
|
}
|
||||||
|
if _, err := pullWikiDocs(ctx, fakeWikiFetcher{nodes: []wikiNode{{id: "node"}}, content: map[string]string{"node": " "}}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}); err == nil {
|
||||||
|
t.Fatal("wiki without readable docs returned nil")
|
||||||
|
}
|
||||||
|
docs, err := pullWikiDocs(ctx, fakeWikiFetcher{nodes: []wikiNode{{id: "node"}}, content: map[string]string{"node": "body"}}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"})
|
||||||
|
if err != nil || len(docs) != 1 || docs[0].stem != "node" {
|
||||||
|
t.Fatalf("unnamed node docs = %#v, %v", docs, err)
|
||||||
|
}
|
||||||
|
if _, err := pullWikiDocs(ctx, fakeWikiFetcher{}, knowledgeSource{kind: knowledgeSourceDir, ref: "dir"}); err == nil {
|
||||||
|
t.Fatal("local source passed to wiki pull returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiCacheFailureRemainingCoverage(t *testing.T) {
|
||||||
|
originalLoad := wikiLoadKnowledgeBase
|
||||||
|
originalRemove := wikiRemoveAll
|
||||||
|
originalMkdir := wikiMkdirAll
|
||||||
|
originalWrite := wikiWriteFile
|
||||||
|
t.Cleanup(func() {
|
||||||
|
wikiLoadKnowledgeBase = originalLoad
|
||||||
|
wikiRemoveAll = originalRemove
|
||||||
|
wikiMkdirAll = originalMkdir
|
||||||
|
wikiWriteFile = originalWrite
|
||||||
|
})
|
||||||
|
boom := errors.New("cache failure")
|
||||||
|
docs := []wikiDoc{{stem: "doc", text: "body"}}
|
||||||
|
|
||||||
|
wikiRemoveAll = func(string) error { return boom }
|
||||||
|
if err := writeWikiCache(t.TempDir(), docs); err == nil {
|
||||||
|
t.Fatal("remove cache error returned nil")
|
||||||
|
}
|
||||||
|
wikiRemoveAll = func(string) error { return nil }
|
||||||
|
wikiMkdirAll = func(string, os.FileMode) error { return boom }
|
||||||
|
if err := writeWikiCache(t.TempDir(), docs); err == nil {
|
||||||
|
t.Fatal("mkdir cache error returned nil")
|
||||||
|
}
|
||||||
|
wikiMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
wikiWriteFile = func(string, []byte, os.FileMode) error { return boom }
|
||||||
|
if err := writeWikiCache(t.TempDir(), docs); err == nil {
|
||||||
|
t.Fatal("write cache error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher := fakeWikiFetcher{content: map[string]string{"doc": "body"}}
|
||||||
|
wikiRemoveAll = func(string) error { return boom }
|
||||||
|
wikiMkdirAll = originalMkdir
|
||||||
|
wikiWriteFile = originalWrite
|
||||||
|
if kb := loadWikiKnowledgeBase(context.Background(), fetcher, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}, t.TempDir(), nil); len(kb.chunks) == 0 {
|
||||||
|
t.Fatal("write failure did not build in memory")
|
||||||
|
}
|
||||||
|
wikiRemoveAll = originalRemove
|
||||||
|
wikiLoadKnowledgeBase = func(string) (*knowledgeBase, error) { return nil, boom }
|
||||||
|
if kb := loadWikiKnowledgeBase(context.Background(), fetcher, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}, t.TempDir(), nil); kb == nil || len(kb.chunks) != 0 {
|
||||||
|
t.Fatalf("load failure base = %#v", kb)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ import (
|
|||||||
// connectLockDir is a var so tests can isolate lock files.
|
// connectLockDir is a var so tests can isolate lock files.
|
||||||
var connectLockDir = os.TempDir()
|
var connectLockDir = os.TempDir()
|
||||||
|
|
||||||
|
var (
|
||||||
|
connectLockOpenFile = os.OpenFile
|
||||||
|
connectLockReadFile = os.ReadFile
|
||||||
|
connectLockRemove = os.Remove
|
||||||
|
)
|
||||||
|
|
||||||
// acquireConnectLock guards against two local connectors holding Stream
|
// acquireConnectLock guards against two local connectors holding Stream
|
||||||
// connections for the same robot. DingTalk load-balances pushes across
|
// connections for the same robot. DingTalk load-balances pushes across
|
||||||
// connections sharing one clientId, so a duplicate connector makes the bot
|
// connections sharing one clientId, so a duplicate connector makes the bot
|
||||||
@@ -35,23 +41,23 @@ var connectLockDir = os.TempDir()
|
|||||||
func acquireConnectLock(clientID string) (release func(), err error) {
|
func acquireConnectLock(clientID string) (release func(), err error) {
|
||||||
path := filepath.Join(connectLockDir, "dws-connect-"+sanitizeLockID(clientID)+".pid")
|
path := filepath.Join(connectLockDir, "dws-connect-"+sanitizeLockID(clientID)+".pid")
|
||||||
for attempt := 0; attempt < 2; attempt++ {
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
f, openErr := connectLockOpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||||
if openErr == nil {
|
if openErr == nil {
|
||||||
fmt.Fprintf(f, "%d", os.Getpid())
|
fmt.Fprintf(f, "%d", os.Getpid())
|
||||||
f.Close()
|
f.Close()
|
||||||
return func() { _ = os.Remove(path) }, nil
|
return func() { _ = connectLockRemove(path) }, nil
|
||||||
}
|
}
|
||||||
if !os.IsExist(openErr) {
|
if !os.IsExist(openErr) {
|
||||||
return nil, openErr
|
return nil, openErr
|
||||||
}
|
}
|
||||||
raw, readErr := os.ReadFile(path)
|
raw, readErr := connectLockReadFile(path)
|
||||||
if readErr == nil {
|
if readErr == nil {
|
||||||
if pid, perr := strconv.Atoi(strings.TrimSpace(string(raw))); perr == nil && pid > 0 && processAlive(pid) {
|
if pid, perr := strconv.Atoi(strings.TrimSpace(string(raw))); perr == nil && pid > 0 && processAlive(pid) {
|
||||||
return nil, fmt.Errorf("机器人 %s 已有连接器在本机运行 (pid %d)。同一机器人的多个 Stream 连接会随机分流消息,表现为机器人时灵时不灵;请先停掉旧进程,或确认后删除 %s", clientID, pid, path)
|
return nil, fmt.Errorf("机器人 %s 已有连接器在本机运行 (pid %d)。同一机器人的多个 Stream 连接会随机分流消息,表现为机器人时灵时不灵;请先停掉旧进程,或确认后删除 %s", clientID, pid, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stale lock (owner gone or file unreadable): take over.
|
// Stale lock (owner gone or file unreadable): take over.
|
||||||
_ = os.Remove(path)
|
_ = connectLockRemove(path)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("无法获取连接器锁 %s", path)
|
return nil, fmt.Errorf("无法获取连接器锁 %s", path)
|
||||||
}
|
}
|
||||||
@@ -70,11 +76,8 @@ func sanitizeLockID(s string) string {
|
|||||||
// processAlive reports whether pid refers to a live process (unix signal-0
|
// processAlive reports whether pid refers to a live process (unix signal-0
|
||||||
// probe; a permission error still means "alive").
|
// probe; a permission error still means "alive").
|
||||||
func processAlive(pid int) bool {
|
func processAlive(pid int) bool {
|
||||||
proc, err := os.FindProcess(pid)
|
proc, _ := os.FindProcess(pid)
|
||||||
if err != nil {
|
err := proc.Signal(syscall.Signal(0))
|
||||||
return false
|
|
||||||
}
|
|
||||||
err = proc.Signal(syscall.Signal(0))
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ func connectAttachmentMIME(path string) string {
|
|||||||
return http.DetectContentType(buf[:n])
|
return http.DetectContentType(buf[:n])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaMkdirAll = os.MkdirAll
|
||||||
|
mediaCreate = os.Create
|
||||||
|
mediaCopy = io.Copy
|
||||||
|
mediaRemove = os.Remove
|
||||||
|
)
|
||||||
|
|
||||||
// pictureDownloadCode digs the downloadCode out of a picture callback's
|
// pictureDownloadCode digs the downloadCode out of a picture callback's
|
||||||
// loosely-typed content payload (the stream SDK models Content as
|
// loosely-typed content payload (the stream SDK models Content as
|
||||||
// interface{}).
|
// interface{}).
|
||||||
@@ -635,7 +642,7 @@ func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode,
|
|||||||
return "", fmt.Errorf("媒体文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
return "", fmt.Errorf("媒体文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||||
}
|
}
|
||||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := mediaMkdirAll(dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
ext := filepath.Ext(strings.TrimSpace(fileName))
|
ext := filepath.Ext(strings.TrimSpace(fileName))
|
||||||
@@ -705,7 +712,7 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
|||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := mediaMkdirAll(dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
ext := filepath.Ext(fileName)
|
ext := filepath.Ext(fileName)
|
||||||
@@ -724,22 +731,22 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
|||||||
// from a larger file; the latter is removed instead of leaving a corrupt local
|
// from a larger file; the latter is removed instead of leaving a corrupt local
|
||||||
// artifact that an agent could mistake for the original.
|
// artifact that an agent could mistake for the original.
|
||||||
func writeCompleteMediaFile(dest string, src io.Reader) error {
|
func writeCompleteMediaFile(dest string, src io.Reader) error {
|
||||||
f, err := os.Create(dest)
|
f, err := mediaCreate(dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
n, copyErr := io.Copy(f, io.LimitReader(src, mediaMaxDownloadBytes+1))
|
n, copyErr := mediaCopy(f, io.LimitReader(src, mediaMaxDownloadBytes+1))
|
||||||
closeErr := f.Close()
|
closeErr := f.Close()
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
_ = os.Remove(dest)
|
_ = mediaRemove(dest)
|
||||||
return copyErr
|
return copyErr
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
_ = os.Remove(dest)
|
_ = mediaRemove(dest)
|
||||||
return closeErr
|
return closeErr
|
||||||
}
|
}
|
||||||
if n > mediaMaxDownloadBytes {
|
if n > mediaMaxDownloadBytes {
|
||||||
_ = os.Remove(dest)
|
_ = mediaRemove(dest)
|
||||||
return fmt.Errorf("媒体文件超过最大允许大小 %d 字节,未保存截断文件", mediaMaxDownloadBytes)
|
return fmt.Errorf("媒体文件超过最大允许大小 %d 字节,未保存截断文件", mediaMaxDownloadBytes)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mediaCoverageResponse(status int, body string, headers map[string]string) *http.Response {
|
||||||
|
header := make(http.Header)
|
||||||
|
for key, value := range headers {
|
||||||
|
header.Set(key, value)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(body))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaCoverageClient(transport roundTripFunc) *aiCardClient {
|
||||||
|
client := newAICardClient("client", "secret", "")
|
||||||
|
client.token = "token"
|
||||||
|
client.tokenExp = time.Now().Add(time.Hour)
|
||||||
|
client.httpClient = &http.Client{Transport: transport}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectMediaPureFunctionRemainingCoverage(t *testing.T) {
|
||||||
|
info := parseFileInbound(map[string]interface{}{
|
||||||
|
"filePath": " /tmp/file ", "fileType": " text/plain ",
|
||||||
|
"dentryID": int64(1), "spaceID": int(2), "size": json.Number("3"),
|
||||||
|
})
|
||||||
|
if info.FilePath != "/tmp/file" || info.FileType != "text/plain" || info.DentryID != 1 || info.SpaceID != 2 || info.FileSize != 3 {
|
||||||
|
t.Fatalf("parsed alternate fields = %#v", info)
|
||||||
|
}
|
||||||
|
for _, value := range []interface{}{json.Number("bad"), "bad", true} {
|
||||||
|
if got := readInt64Field(map[string]interface{}{"value": value}, "missing", "value"); got != 0 {
|
||||||
|
t.Errorf("readInt64Field(%v) = %d", value, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := summarizeContent(make(chan int)); !strings.Contains(got, "unmarshalable") {
|
||||||
|
t.Fatalf("unmarshalable summary = %q", got)
|
||||||
|
}
|
||||||
|
if got := summarizeContent(strings.Repeat("x", 500)); len(got) <= 400 || !strings.HasSuffix(got, "…") {
|
||||||
|
t.Fatalf("long summary length/suffix = %d/%q", len(got), got[len(got)-3:])
|
||||||
|
}
|
||||||
|
leaves := cardContentLeaves([]interface{}{1, map[string]interface{}{"value": "text"}})
|
||||||
|
if len(leaves) != 1 || leaves[0] != "text" {
|
||||||
|
t.Fatalf("card leaves = %#v", leaves)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ rawURL, contentType, want string }{
|
||||||
|
{"https://x/y", "image/gif", ".gif"},
|
||||||
|
{"https://x/y", "image/webp", ".webp"},
|
||||||
|
{"https://x/file.toolong", "", ".png"},
|
||||||
|
{"%", "", ".png"},
|
||||||
|
} {
|
||||||
|
if got := mediaExt(tc.rawURL, tc.contentType); got != tc.want {
|
||||||
|
t.Errorf("mediaExt(%q, %q) = %q, want %q", tc.rawURL, tc.contentType, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadMessageFileRemainingCoverage(t *testing.T) {
|
||||||
|
withCardAPIBase(t, "https://api.test")
|
||||||
|
boom := errors.New("media failure")
|
||||||
|
originalMkdir := mediaMkdirAll
|
||||||
|
originalCreate := mediaCreate
|
||||||
|
originalCopy := mediaCopy
|
||||||
|
originalRemove := mediaRemove
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mediaMkdirAll = originalMkdir
|
||||||
|
mediaCreate = originalCreate
|
||||||
|
mediaCopy = originalCopy
|
||||||
|
mediaRemove = originalRemove
|
||||||
|
})
|
||||||
|
|
||||||
|
client := mediaCoverageClient(func(*http.Request) (*http.Response, error) { return nil, boom })
|
||||||
|
if _, err := client.downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("API error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
client = mediaCoverageClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return mediaCoverageResponse(200, `{"downloadUrl":":"}`, nil), nil
|
||||||
|
})
|
||||||
|
if _, err := client.downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("invalid download URL returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
client = mediaCoverageClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.test" {
|
||||||
|
return mediaCoverageResponse(200, `{"downloadUrl":"https://download.test/file"}`, nil), nil
|
||||||
|
}
|
||||||
|
return nil, boom
|
||||||
|
})
|
||||||
|
if _, err := client.downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("download transport error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClient := func(status int) *aiCardClient {
|
||||||
|
return mediaCoverageClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.test" {
|
||||||
|
return mediaCoverageResponse(200, `{"downloadUrl":"https://download.test/file"}`, nil), nil
|
||||||
|
}
|
||||||
|
return mediaCoverageResponse(status, "body", map[string]string{"Content-Type": "image/png"}), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := statusClient(500).downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("HTTP status error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaMkdirAll = func(string, os.FileMode) error { return boom }
|
||||||
|
if _, err := statusClient(200).downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("mkdir error returned nil")
|
||||||
|
}
|
||||||
|
mediaMkdirAll = originalMkdir
|
||||||
|
mediaCreate = func(string) (*os.File, error) { return nil, boom }
|
||||||
|
if _, err := statusClient(200).downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("create error returned nil")
|
||||||
|
}
|
||||||
|
mediaCreate = originalCreate
|
||||||
|
mediaCopy = func(io.Writer, io.Reader) (int64, error) { return 0, boom }
|
||||||
|
if _, err := statusClient(200).downloadMessageFile(context.Background(), "robot", "code"); err == nil {
|
||||||
|
t.Fatal("copy error returned nil")
|
||||||
|
}
|
||||||
|
mediaCopy = originalCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDentryMediaRemainingCoverage(t *testing.T) {
|
||||||
|
withCardAPIBase(t, "https://api.test")
|
||||||
|
boom := errors.New("dentry failure")
|
||||||
|
|
||||||
|
client := mediaCoverageClient(func(*http.Request) (*http.Response, error) { return nil, boom })
|
||||||
|
if _, err := client.getUserUnionID(context.Background(), "user"); err == nil {
|
||||||
|
t.Fatal("union API error returned nil")
|
||||||
|
}
|
||||||
|
client = mediaCoverageClient(func(*http.Request) (*http.Response, error) {
|
||||||
|
return mediaCoverageResponse(200, `{}`, nil), nil
|
||||||
|
})
|
||||||
|
if _, err := client.getUserUnionID(context.Background(), "user"); err == nil {
|
||||||
|
t.Fatal("missing union ID returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
client = mediaCoverageClient(func(*http.Request) (*http.Response, error) { return nil, boom })
|
||||||
|
if _, err := client.downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil {
|
||||||
|
t.Fatal("download info API error returned nil")
|
||||||
|
}
|
||||||
|
client = mediaCoverageClient(func(*http.Request) (*http.Response, error) {
|
||||||
|
return mediaCoverageResponse(200, `{}`, nil), nil
|
||||||
|
})
|
||||||
|
if _, err := client.downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil {
|
||||||
|
t.Fatal("missing resource URL returned nil")
|
||||||
|
}
|
||||||
|
client = mediaCoverageClient(func(*http.Request) (*http.Response, error) {
|
||||||
|
return mediaCoverageResponse(200, `{"resourceUrl":":"}`, nil), nil
|
||||||
|
})
|
||||||
|
if _, err := client.downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil {
|
||||||
|
t.Fatal("invalid resource URL returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dentryClient := func(status int, downloadErr error) *aiCardClient {
|
||||||
|
return mediaCoverageClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Host == "api.test" {
|
||||||
|
return mediaCoverageResponse(200, `{"resourceUrl":"https://download.test/file","headers":{"X-Test":"value"}}`, nil), nil
|
||||||
|
}
|
||||||
|
if downloadErr != nil {
|
||||||
|
return nil, downloadErr
|
||||||
|
}
|
||||||
|
if req.Header.Get("X-Test") != "value" {
|
||||||
|
t.Errorf("download header = %q", req.Header.Get("X-Test"))
|
||||||
|
}
|
||||||
|
return mediaCoverageResponse(status, "body", map[string]string{"Content-Type": "image/gif"}), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := dentryClient(200, boom).downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil {
|
||||||
|
t.Fatal("download transport error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := dentryClient(500, nil).downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil {
|
||||||
|
t.Fatal("download HTTP status returned nil")
|
||||||
|
}
|
||||||
|
path, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extension fallback success: %v", err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
|
||||||
|
originalMkdir := mediaMkdirAll
|
||||||
|
originalCreate := mediaCreate
|
||||||
|
originalCopy := mediaCopy
|
||||||
|
t.Cleanup(func() {
|
||||||
|
mediaMkdirAll = originalMkdir
|
||||||
|
mediaCreate = originalCreate
|
||||||
|
mediaCopy = originalCopy
|
||||||
|
})
|
||||||
|
mediaMkdirAll = func(string, os.FileMode) error { return boom }
|
||||||
|
if _, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", ""); err == nil {
|
||||||
|
t.Fatal("dentry mkdir error returned nil")
|
||||||
|
}
|
||||||
|
mediaMkdirAll = originalMkdir
|
||||||
|
mediaCreate = func(string) (*os.File, error) { return nil, boom }
|
||||||
|
if _, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", "file.txt"); err == nil {
|
||||||
|
t.Fatal("dentry create error returned nil")
|
||||||
|
}
|
||||||
|
mediaCreate = originalCreate
|
||||||
|
mediaCopy = func(io.Writer, io.Reader) (int64, error) { return 0, boom }
|
||||||
|
if _, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", "file.txt"); err == nil {
|
||||||
|
t.Fatal("dentry copy error returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,12 +34,17 @@ type connectCreds struct {
|
|||||||
source string // how they were resolved, for the [connect] log line
|
source string // how they were resolved, for the [connect] log line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
connectStdinStat = func() (os.FileInfo, error) { return os.Stdin.Stat() }
|
||||||
|
connectOnboardingSleep = sleepCtx
|
||||||
|
)
|
||||||
|
|
||||||
// connectStdinInteractive reports whether stdin is a real terminal (char
|
// connectStdinInteractive reports whether stdin is a real terminal (char
|
||||||
// device), so the onboarding prompt only runs interactively and never blocks a
|
// device), so the onboarding prompt only runs interactively and never blocks a
|
||||||
// script, a daemon, or a piped invocation — those keep the original
|
// script, a daemon, or a piped invocation — those keep the original
|
||||||
// "need a flag" error.
|
// "need a flag" error.
|
||||||
func connectStdinInteractive() bool {
|
func connectStdinInteractive() bool {
|
||||||
fi, err := os.Stdin.Stat()
|
fi, err := connectStdinStat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -171,7 +176,7 @@ func pollRobotCreateResult(runner executor.Runner, cmd *cobra.Command, taskID st
|
|||||||
}
|
}
|
||||||
if attempt < maxAttempts {
|
if attempt < maxAttempts {
|
||||||
fmt.Fprintf(out, " 建号进行中(%d/%d,状态=%s),稍候…\n", attempt, maxAttempts, status)
|
fmt.Fprintf(out, " 建号进行中(%d/%d,状态=%s),稍候…\n", attempt, maxAttempts, status)
|
||||||
_ = sleepCtx(cmd.Context(), 3*time.Second)
|
_ = connectOnboardingSleep(cmd.Context(), 3*time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return connectCreds{}, apperrors.NewInternal(fmt.Sprintf("建号结果轮询超时,taskId=%s;可稍后用 `dws dev app robot result --task-id %s` 继续查询", taskID, taskID))
|
return connectCreds{}, apperrors.NewInternal(fmt.Sprintf("建号结果轮询超时,taskId=%s;可稍后用 `dws dev app robot result --task-id %s` 继续查询", taskID, taskID))
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type onboardingCoverageRunner struct {
|
||||||
|
responses []map[string]any
|
||||||
|
errors []error
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingCoverageRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||||
|
index := r.index
|
||||||
|
r.index++
|
||||||
|
if index < len(r.errors) && r.errors[index] != nil {
|
||||||
|
return executor.Result{}, r.errors[index]
|
||||||
|
}
|
||||||
|
if index < len(r.responses) {
|
||||||
|
return executor.Result{Response: r.responses[index]}, nil
|
||||||
|
}
|
||||||
|
return executor.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectOnboardingFailureCoverage(t *testing.T) {
|
||||||
|
boom := errors.New("onboarding failure")
|
||||||
|
originalStat := connectStdinStat
|
||||||
|
originalSleep := connectOnboardingSleep
|
||||||
|
t.Cleanup(func() {
|
||||||
|
connectStdinStat = originalStat
|
||||||
|
connectOnboardingSleep = originalSleep
|
||||||
|
})
|
||||||
|
connectStdinStat = func() (os.FileInfo, error) { return nil, boom }
|
||||||
|
if connectStdinInteractive() {
|
||||||
|
t.Fatal("stdin stat error reported interactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if _, err := runConnectOnboarding(&onboardingCoverageRunner{}, &cobra.Command{}, strings.NewReader(""), &out); err == nil {
|
||||||
|
t.Fatal("choice EOF returned nil")
|
||||||
|
}
|
||||||
|
if _, err := onboardExistingApp(&onboardingCoverageRunner{}, &cobra.Command{}, bufio.NewReader(strings.NewReader("")), &out); err == nil {
|
||||||
|
t.Fatal("existing app EOF returned nil")
|
||||||
|
}
|
||||||
|
if _, err := onboardExistingApp(&onboardingCoverageRunner{errors: []error{boom}}, &cobra.Command{}, bufio.NewReader(strings.NewReader("app\n")), &out); err == nil {
|
||||||
|
t.Fatal("credential fetch error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := onboardExistingApp(&onboardingCoverageRunner{responses: []map[string]any{{}}}, &cobra.Command{}, bufio.NewReader(strings.NewReader("app\n")), &out); err == nil {
|
||||||
|
t.Fatal("empty fetched credentials returned nil")
|
||||||
|
}
|
||||||
|
if _, err := onboardExistingApp(&onboardingCoverageRunner{}, &cobra.Command{}, bufio.NewReader(strings.NewReader("\nclient\n\n")), &out); err == nil {
|
||||||
|
t.Fatal("empty raw secret returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
input := func() *bufio.Reader { return bufio.NewReader(strings.NewReader("app\nrobot\ndescription\n")) }
|
||||||
|
if _, err := onboardNewApp(&onboardingCoverageRunner{errors: []error{boom}}, &cobra.Command{}, input(), &out); err == nil {
|
||||||
|
t.Fatal("submit error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := onboardNewApp(&onboardingCoverageRunner{responses: []map[string]any{{}}}, &cobra.Command{}, input(), &out); err == nil {
|
||||||
|
t.Fatal("empty task ID returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
connectOnboardingSleep = func(context.Context, time.Duration) error { return nil }
|
||||||
|
if _, err := pollRobotCreateResult(&onboardingCoverageRunner{errors: []error{boom}}, &cobra.Command{}, "task", &out); err == nil {
|
||||||
|
t.Fatal("poll runner error returned nil")
|
||||||
|
}
|
||||||
|
if _, err := pollRobotCreateResult(&onboardingCoverageRunner{responses: []map[string]any{{"status": "failed"}}}, &cobra.Command{}, "task", &out); err == nil {
|
||||||
|
t.Fatal("failed task returned nil")
|
||||||
|
}
|
||||||
|
responses := make([]map[string]any, 10)
|
||||||
|
for index := range responses {
|
||||||
|
responses[index] = map[string]any{"status": "pending"}
|
||||||
|
}
|
||||||
|
if _, err := pollRobotCreateResult(&onboardingCoverageRunner{responses: responses}, &cobra.Command{}, "task", &out); err == nil {
|
||||||
|
t.Fatal("poll timeout returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ const (
|
|||||||
opencodeServerUsername = "opencode"
|
opencodeServerUsername = "opencode"
|
||||||
opencodeServerHealthPath = "/global/health"
|
opencodeServerHealthPath = "/global/health"
|
||||||
opencodeNoTextReply = "(本地 agent 无文本输出)"
|
opencodeNoTextReply = "(本地 agent 无文本输出)"
|
||||||
opencodeServerStartupWait = 20 * time.Second
|
|
||||||
opencodeServerPollInterval = 150 * time.Millisecond
|
opencodeServerPollInterval = 150 * time.Millisecond
|
||||||
opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT"
|
opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT"
|
||||||
opencodePermissionEnv = "OPENCODE_PERMISSION"
|
opencodePermissionEnv = "OPENCODE_PERMISSION"
|
||||||
@@ -51,6 +50,18 @@ const (
|
|||||||
|
|
||||||
var errOpencodeSessionMissing = errors.New("opencode session missing")
|
var errOpencodeSessionMissing = errors.New("opencode session missing")
|
||||||
|
|
||||||
|
var (
|
||||||
|
opencodeServerStartupWait = 20 * time.Second
|
||||||
|
opencodeAbsPath = filepath.Abs
|
||||||
|
opencodeFreeLocalPort = freeLocalPort
|
||||||
|
opencodeExecCommand = exec.Command
|
||||||
|
opencodeRandRead = rand.Read
|
||||||
|
opencodeWaitHealthy = func(s *opencodeServer, ctx context.Context, client *opencodeHTTPClient) error {
|
||||||
|
return s.waitHealthy(ctx, client)
|
||||||
|
}
|
||||||
|
freeLocalPortListen = net.Listen
|
||||||
|
)
|
||||||
|
|
||||||
// opencodeForwarder forwards DingTalk messages to a local `opencode serve`
|
// opencodeForwarder forwards DingTalk messages to a local `opencode serve`
|
||||||
// instance. DWS owns the local server process and keeps a per-conversation
|
// instance. DWS owns the local server process and keeps a per-conversation
|
||||||
// mapping to opencode's native session id; opencode owns the actual message
|
// mapping to opencode's native session id; opencode owns the actual message
|
||||||
@@ -158,7 +169,7 @@ func (f *opencodeForwarder) cwd() string {
|
|||||||
if f.workDir == "" {
|
if f.workDir == "" {
|
||||||
return connectWorkDir()
|
return connectWorkDir()
|
||||||
}
|
}
|
||||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
if abs, err := opencodeAbsPath(f.workDir); err == nil {
|
||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
return f.workDir
|
return f.workDir
|
||||||
@@ -241,13 +252,13 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
|
|||||||
_ = s.closeLocked()
|
_ = s.closeLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
port, err := freeLocalPort()
|
port, err := opencodeFreeLocalPort()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
password := randomHex(24)
|
password := randomHex(24)
|
||||||
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
baseURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||||
cmd := exec.Command(s.bin, "serve", "--pure", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port))
|
cmd := opencodeExecCommand(s.bin, "serve", "--pure", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port))
|
||||||
if s.workDir != "" {
|
if s.workDir != "" {
|
||||||
cmd.Dir = s.workDir
|
cmd.Dir = s.workDir
|
||||||
}
|
}
|
||||||
@@ -266,7 +277,7 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
|
|||||||
s.done <- cmd.Wait()
|
s.done <- cmd.Wait()
|
||||||
}()
|
}()
|
||||||
client = &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient}
|
client = &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient}
|
||||||
if err := s.waitHealthy(ctx, client); err != nil {
|
if err := opencodeWaitHealthy(s, ctx, client); err != nil {
|
||||||
_ = s.closeLocked()
|
_ = s.closeLocked()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -305,18 +316,12 @@ func opencodeNonInteractiveConfig(existing string) string {
|
|||||||
}
|
}
|
||||||
cfg["permission"] = permission
|
cfg["permission"] = permission
|
||||||
|
|
||||||
data, err := json.Marshal(cfg)
|
data, _ := json.Marshal(cfg)
|
||||||
if err != nil {
|
|
||||||
return `{"tools":{"question":false},"permission":{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}}`
|
|
||||||
}
|
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func opencodeNonInteractivePermission() string {
|
func opencodeNonInteractivePermission() string {
|
||||||
data, err := json.Marshal(opencodeNonInteractivePermissionMap())
|
data, _ := json.Marshal(opencodeNonInteractivePermissionMap())
|
||||||
if err != nil {
|
|
||||||
return `{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}`
|
|
||||||
}
|
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,9 +502,6 @@ func (c *opencodeHTTPClient) sendMessageWithAttachments(ctx context.Context, ses
|
|||||||
Parts []opencodePart `json:"parts"`
|
Parts []opencodePart `json:"parts"`
|
||||||
}
|
}
|
||||||
if err := c.doJSON(ctx, http.MethodPost, "/session/"+sessionID+"/message", body, &out); err != nil {
|
if err := c.doJSON(ctx, http.MethodPost, "/session/"+sessionID+"/message", body, &out); err != nil {
|
||||||
if errors.Is(err, errOpencodeSessionMissing) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(opencodePartsText(out.Parts)), nil
|
return strings.TrimSpace(opencodePartsText(out.Parts)), nil
|
||||||
@@ -607,7 +609,7 @@ func opencodeModelRef(model string) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func freeLocalPort() (int, error) {
|
func freeLocalPort() (int, error) {
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
ln, err := freeLocalPortListen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -617,7 +619,7 @@ func freeLocalPort() (int, error) {
|
|||||||
|
|
||||||
func randomHex(bytesLen int) string {
|
func randomHex(bytesLen int) string {
|
||||||
buf := make([]byte, bytesLen)
|
buf := make([]byte, bytesLen)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := opencodeRandRead(buf); err != nil {
|
||||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(buf)
|
return hex.EncodeToString(buf)
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func opencodeTestClient(t *testing.T, handler http.HandlerFunc) (*opencodeHTTPClient, func()) {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
return &opencodeHTTPClient{baseURL: server.URL, username: "user", password: "pass", httpClient: server.Client()}, server.Close
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeForwarderRemainingEdges(t *testing.T) {
|
||||||
|
t.Run("create session error", func(t *testing.T) {
|
||||||
|
client, closeServer := opencodeTestClient(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "failed", http.StatusInternalServerError) })
|
||||||
|
defer closeServer()
|
||||||
|
f := &opencodeForwarder{server: &opencodeServer{}, sessions: newOpencodeSessions("")}
|
||||||
|
if _, err := f.forwardWithClient(context.Background(), client, "conv", "text", nil); err == nil {
|
||||||
|
t.Fatal("create error returned nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("message error and backend reply", func(t *testing.T) {
|
||||||
|
response := "error"
|
||||||
|
client, closeServer := opencodeTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/session" {
|
||||||
|
_, _ = io.WriteString(w, `{"id":"session"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if response == "error" {
|
||||||
|
http.Error(w, "failed", http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
_, _ = io.WriteString(w, `{"parts":[{"type":"text","text":"API Error: backend"}]}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer closeServer()
|
||||||
|
f := &opencodeForwarder{server: &opencodeServer{}}
|
||||||
|
if _, err := f.forwardWithClient(context.Background(), client, "conv", "text", nil); err == nil {
|
||||||
|
t.Fatal("message error returned nil")
|
||||||
|
}
|
||||||
|
response = "backend"
|
||||||
|
if reply, err := f.forwardWithClient(context.Background(), client, "conv", "text", nil); err != nil || !strings.Contains(reply, "backend") {
|
||||||
|
t.Fatalf("backend reply=%q error=%v", reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
origAbs := opencodeAbsPath
|
||||||
|
t.Cleanup(func() { opencodeAbsPath = origAbs })
|
||||||
|
opencodeAbsPath = func(string) (string, error) { return "", errors.New("abs") }
|
||||||
|
f := &opencodeForwarder{workDir: "relative"}
|
||||||
|
if f.cwd() != "relative" {
|
||||||
|
t.Fatalf("cwd fallback = %q", f.cwd())
|
||||||
|
}
|
||||||
|
opencodeAbsPath = filepath.Abs
|
||||||
|
if got := (&opencodeForwarder{workDir: "."}).cwd(); !filepath.IsAbs(got) {
|
||||||
|
t.Fatalf("absolute cwd = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&opencodeForwarder{}).clearSession(context.Background(), "conv"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.sessions = newOpencodeSessions("")
|
||||||
|
if err := f.clearSession(context.Background(), "conv"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.sessions.set("conv", "session")
|
||||||
|
f.server = newOpencodeServer(filepath.Join(t.TempDir(), "missing"), nil, "", false)
|
||||||
|
if err := f.clearSession(context.Background(), "conv"); err == nil {
|
||||||
|
t.Fatal("server ensure error returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeServerEnsureAndCloseEdges(t *testing.T) {
|
||||||
|
origPort := opencodeFreeLocalPort
|
||||||
|
origCommand := opencodeExecCommand
|
||||||
|
origWait := opencodeServerStartupWait
|
||||||
|
origHealthy := opencodeWaitHealthy
|
||||||
|
t.Cleanup(func() {
|
||||||
|
opencodeFreeLocalPort = origPort
|
||||||
|
opencodeExecCommand = origCommand
|
||||||
|
opencodeServerStartupWait = origWait
|
||||||
|
opencodeWaitHealthy = origHealthy
|
||||||
|
})
|
||||||
|
|
||||||
|
healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"healthy":true}`)
|
||||||
|
}))
|
||||||
|
defer healthy.Close()
|
||||||
|
s := &opencodeServer{baseURL: healthy.URL}
|
||||||
|
if _, err := s.ensure(context.Background()); err != nil || s.httpClient == nil {
|
||||||
|
t.Fatalf("existing server ensure: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opencodeFreeLocalPort = func() (int, error) { return 0, errors.New("port") }
|
||||||
|
if _, err := newOpencodeServer("bin", nil, "", false).ensure(context.Background()); err == nil {
|
||||||
|
t.Fatal("port error returned nil")
|
||||||
|
}
|
||||||
|
opencodeFreeLocalPort = origPort
|
||||||
|
if _, err := newOpencodeServer(filepath.Join(t.TempDir(), "missing"), nil, "", false).ensure(context.Background()); err == nil {
|
||||||
|
t.Fatal("start error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
opencodeExecCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "sleep 5") }
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
s = newOpencodeServer("ignored", nil, t.TempDir(), false)
|
||||||
|
if _, err := s.ensure(ctx); err == nil {
|
||||||
|
t.Fatal("cancelled startup returned nil")
|
||||||
|
}
|
||||||
|
if s.cmd != nil || s.baseURL != "" {
|
||||||
|
t.Fatalf("failed startup not cleaned: %#v", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful injected readiness check covers the final state return; the
|
||||||
|
// real waitHealthy state machine is exercised independently below.
|
||||||
|
opencodeWaitHealthy = func(*opencodeServer, context.Context, *opencodeHTTPClient) error { return nil }
|
||||||
|
opencodeExecCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "sleep 5") }
|
||||||
|
s = newOpencodeServer("ignored", nil, "", false)
|
||||||
|
if client, err := s.ensure(context.Background()); err != nil || client == nil {
|
||||||
|
t.Fatalf("successful startup: client=%v err=%v", client, err)
|
||||||
|
}
|
||||||
|
_ = s.close()
|
||||||
|
|
||||||
|
// Existing unhealthy process is closed before attempting a replacement.
|
||||||
|
proc := exec.Command("sh", "-c", "sleep 5")
|
||||||
|
if err := proc.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func(cmd *exec.Cmd) { done <- cmd.Wait() }(proc)
|
||||||
|
s = &opencodeServer{cmd: proc, done: done, baseURL: "http://127.0.0.1:1", httpClient: &http.Client{}}
|
||||||
|
opencodeFreeLocalPort = func() (int, error) { return 0, errors.New("after-close") }
|
||||||
|
if _, err := s.ensure(context.Background()); err == nil {
|
||||||
|
t.Fatal("replacement error returned nil")
|
||||||
|
}
|
||||||
|
opencodeFreeLocalPort = origPort
|
||||||
|
|
||||||
|
proc = exec.Command("sh", "-c", "sleep 5")
|
||||||
|
if err := proc.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done = make(chan error, 1)
|
||||||
|
go func(cmd *exec.Cmd) { done <- cmd.Wait() }(proc)
|
||||||
|
s = &opencodeServer{cmd: proc, done: done, baseURL: "url", password: "pw"}
|
||||||
|
if err := s.close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeWaitHealthyRemainingEdges(t *testing.T) {
|
||||||
|
origWait := opencodeServerStartupWait
|
||||||
|
origSleep := helperSleep
|
||||||
|
t.Cleanup(func() {
|
||||||
|
opencodeServerStartupWait = origWait
|
||||||
|
helperSleep = origSleep
|
||||||
|
})
|
||||||
|
helperSleep = func(time.Duration) { time.Sleep(time.Millisecond) }
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
s := &opencodeServer{}
|
||||||
|
if err := s.waitHealthy(ctx, &opencodeHTTPClient{}); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancel error=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, processErr := range []error{errors.New("exit"), nil} {
|
||||||
|
s = &opencodeServer{done: make(chan error, 1)}
|
||||||
|
s.done <- processErr
|
||||||
|
if err := s.waitHealthy(context.Background(), &opencodeHTTPClient{}); err == nil {
|
||||||
|
t.Fatal("process exit returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opencodeServerStartupWait = 3 * time.Millisecond
|
||||||
|
bad := &opencodeHTTPClient{baseURL: "http://127.0.0.1:1", httpClient: &http.Client{}}
|
||||||
|
if err := s.waitHealthy(context.Background(), bad); err == nil || !strings.Contains(err.Error(), "超时") {
|
||||||
|
t.Fatalf("health timeout=%v", err)
|
||||||
|
}
|
||||||
|
opencodeServerStartupWait = 0
|
||||||
|
if err := s.waitHealthy(context.Background(), bad); err == nil {
|
||||||
|
t.Fatal("empty timeout returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeHTTPClientRemainingEdges(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
empty := &opencodeHTTPClient{httpClient: &http.Client{}}
|
||||||
|
if err := empty.health(ctx); err == nil {
|
||||||
|
t.Fatal("empty URL health returned nil")
|
||||||
|
}
|
||||||
|
if err := empty.deleteSession(ctx, " "); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, closeServer := opencodeTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/global/health":
|
||||||
|
_, _ = io.WriteString(w, `{"healthy":false}`)
|
||||||
|
case "/session-empty":
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
case "/session/missing":
|
||||||
|
http.NotFound(w, r)
|
||||||
|
case "/session/fail":
|
||||||
|
http.Error(w, "delete failed", http.StatusInternalServerError)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer closeServer()
|
||||||
|
if err := client.health(ctx); err == nil {
|
||||||
|
t.Fatal("health=false returned nil")
|
||||||
|
}
|
||||||
|
// Point createSession at a server whose /session response has no id.
|
||||||
|
missingID, closeMissing := opencodeTestClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{}`) })
|
||||||
|
defer closeMissing()
|
||||||
|
if _, err := missingID.createSession(ctx); err == nil {
|
||||||
|
t.Fatal("missing session id returned nil")
|
||||||
|
}
|
||||||
|
messageClient, closeMessage := opencodeTestClient(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, `{"parts":[{"type":"text","text":"reply"}]}`)
|
||||||
|
})
|
||||||
|
defer closeMessage()
|
||||||
|
if reply, err := messageClient.sendMessageWithAttachments(ctx, "session", "text", "provider/model", nil); err != nil || reply != "reply" {
|
||||||
|
t.Fatalf("modeled message reply=%q err=%v", reply, err)
|
||||||
|
}
|
||||||
|
if err := client.deleteSession(ctx, "missing"); err != nil {
|
||||||
|
t.Fatalf("missing delete: %v", err)
|
||||||
|
}
|
||||||
|
if err := client.deleteSession(ctx, "fail"); err == nil {
|
||||||
|
t.Fatal("failed delete returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeDoJSONAndPartsEdges(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := &opencodeHTTPClient{baseURL: "http://example.invalid", httpClient: &http.Client{}}
|
||||||
|
if err := client.doJSON(ctx, http.MethodPost, "/x", make(chan int), nil); err == nil {
|
||||||
|
t.Fatal("marshal error returned nil")
|
||||||
|
}
|
||||||
|
client.baseURL = "%"
|
||||||
|
if err := client.doJSON(ctx, http.MethodGet, "/x", nil, nil); err == nil {
|
||||||
|
t.Fatal("request error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := []struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
out any
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{http.StatusInternalServerError, "", nil, true},
|
||||||
|
{http.StatusInternalServerError, "failure", nil, true},
|
||||||
|
{http.StatusNoContent, "", &map[string]any{}, false},
|
||||||
|
{http.StatusOK, "{", &map[string]any{}, true},
|
||||||
|
{http.StatusOK, `{}`, nil, false},
|
||||||
|
}
|
||||||
|
for _, tc := range responses {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(tc.status)
|
||||||
|
_, _ = io.WriteString(w, tc.body)
|
||||||
|
}))
|
||||||
|
c := &opencodeHTTPClient{baseURL: server.URL, username: "u", password: "p", httpClient: server.Client()}
|
||||||
|
err := c.doJSON(ctx, http.MethodPost, "/x", map[string]any{"x": 1}, tc.out)
|
||||||
|
server.Close()
|
||||||
|
if (err != nil) != tc.err {
|
||||||
|
t.Fatalf("status=%d body=%q error=%v wantErr=%v", tc.status, tc.body, err, tc.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
closedURL := closed.URL
|
||||||
|
closedClient := closed.Client()
|
||||||
|
closed.Close()
|
||||||
|
if err := (&opencodeHTTPClient{baseURL: closedURL, httpClient: closedClient}).doJSON(ctx, http.MethodGet, "/x", nil, nil); err == nil {
|
||||||
|
t.Fatal("transport error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []opencodePart{
|
||||||
|
{Type: "text", Delta: "delta", Data: []byte(`{"text":"data"}`)},
|
||||||
|
{Type: "text", Data: []byte(`{`)},
|
||||||
|
{Parts: []opencodePart{{Type: "text", Text: "nested"}}},
|
||||||
|
}
|
||||||
|
if got := opencodePartsText(parts); !strings.Contains(got, "delta") || !strings.Contains(got, "data") || !strings.Contains(got, "nested") {
|
||||||
|
t.Fatalf("parts text=%q", got)
|
||||||
|
}
|
||||||
|
for _, model := range []string{"", "model", "/missing", "provider/", "provider/model"} {
|
||||||
|
_ = opencodeModelRef(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeSystemHelperEdges(t *testing.T) {
|
||||||
|
origPort := opencodeFreeLocalPort
|
||||||
|
origRand := opencodeRandRead
|
||||||
|
origListen := freeLocalPortListen
|
||||||
|
t.Cleanup(func() {
|
||||||
|
opencodeFreeLocalPort = origPort
|
||||||
|
opencodeRandRead = origRand
|
||||||
|
freeLocalPortListen = origListen
|
||||||
|
})
|
||||||
|
opencodeRandRead = func([]byte) (int, error) { return 0, errors.New("rand") }
|
||||||
|
if randomHex(4) == "" {
|
||||||
|
t.Fatal("random fallback empty")
|
||||||
|
}
|
||||||
|
freeLocalPortListen = func(string, string) (net.Listener, error) { return nil, errors.New("listen") }
|
||||||
|
if _, err := freeLocalPort(); err == nil {
|
||||||
|
t.Fatal("listen error returned nil")
|
||||||
|
}
|
||||||
|
if opencodeConvKey(" ") != "_default" {
|
||||||
|
t.Fatal("blank conversation key")
|
||||||
|
}
|
||||||
|
sessions := newOpencodeSessions("")
|
||||||
|
sessions.set("conv", "session")
|
||||||
|
sessions.set("conv", "")
|
||||||
|
}
|
||||||
@@ -31,6 +31,12 @@ import (
|
|||||||
|
|
||||||
const qoderInitializeTimeout = 60 * time.Second
|
const qoderInitializeTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
var (
|
||||||
|
qoderAbsPath = filepath.Abs
|
||||||
|
qoderExecCommand = exec.Command
|
||||||
|
qoderUUIDString = uuid.NewString
|
||||||
|
)
|
||||||
|
|
||||||
// qoderStreamForwarder keeps one qodercli stream-json subprocess alive for the
|
// qoderStreamForwarder keeps one qodercli stream-json subprocess alive for the
|
||||||
// lifetime of `dws dev connect`. DWS sends each DingTalk turn as a JSON user
|
// lifetime of `dws dev connect`. DWS sends each DingTalk turn as a JSON user
|
||||||
// message with a per-conversation session_id, so Qoder keeps context without a
|
// message with a per-conversation session_id, so Qoder keeps context without a
|
||||||
@@ -142,7 +148,7 @@ func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text s
|
|||||||
if err := f.ensureLocked(ctx); err != nil {
|
if err := f.ensureLocked(ctx); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
sessionID := uuid.NewString()
|
sessionID := qoderUUIDString()
|
||||||
if f.sessions != nil {
|
if f.sessions != nil {
|
||||||
sessionID = f.sessions.id(convID)
|
sessionID = f.sessions.id(convID)
|
||||||
}
|
}
|
||||||
@@ -234,7 +240,7 @@ func (f *qoderStreamForwarder) cwd() string {
|
|||||||
if f.workDir == "" {
|
if f.workDir == "" {
|
||||||
return connectWorkDir()
|
return connectWorkDir()
|
||||||
}
|
}
|
||||||
if abs, err := filepath.Abs(f.workDir); err == nil {
|
if abs, err := qoderAbsPath(f.workDir); err == nil {
|
||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
return f.workDir
|
return f.workDir
|
||||||
@@ -263,7 +269,7 @@ func (f *qoderStreamForwarder) ensureLocked(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(f.bin, f.commandArgs()...)
|
cmd := qoderExecCommand(f.bin, f.commandArgs()...)
|
||||||
cmd.Dir = f.cwd()
|
cmd.Dir = f.cwd()
|
||||||
cmd.Env = append(os.Environ(), f.env...)
|
cmd.Env = append(os.Environ(), f.env...)
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
@@ -296,7 +302,7 @@ func (f *qoderStreamForwarder) ensureLocked(ctx context.Context) error {
|
|||||||
func (f *qoderStreamForwarder) initializeLocked(parent context.Context) error {
|
func (f *qoderStreamForwarder) initializeLocked(parent context.Context) error {
|
||||||
ctx, cancel := context.WithTimeout(parent, qoderInitializeTimeout)
|
ctx, cancel := context.WithTimeout(parent, qoderInitializeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
requestID := "dws_init_" + uuid.NewString()
|
requestID := "dws_init_" + qoderUUIDString()
|
||||||
request := map[string]any{
|
request := map[string]any{
|
||||||
"type": "control_request",
|
"type": "control_request",
|
||||||
"request_id": requestID,
|
"request_id": requestID,
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type qoderErrorWriteCloser struct{ err error }
|
||||||
|
|
||||||
|
func (w qoderErrorWriteCloser) Write([]byte) (int, error) { return 0, w.err }
|
||||||
|
func (qoderErrorWriteCloser) Close() error { return nil }
|
||||||
|
|
||||||
|
type qoderCloseRecorder struct{ closed bool }
|
||||||
|
|
||||||
|
func (*qoderCloseRecorder) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
func (w *qoderCloseRecorder) Close() error { w.closed = true; return nil }
|
||||||
|
|
||||||
|
func activeQoderForwarder(stdin io.WriteCloser) *qoderStreamForwarder {
|
||||||
|
return &qoderStreamForwarder{
|
||||||
|
name: "qoder", stdin: stdin, cmd: &exec.Cmd{},
|
||||||
|
lines: make(chan string, 10), done: make(chan error), stderr: &lockedStringBuffer{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installImmediateQoderAfter(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
orig := helperAfter
|
||||||
|
helperAfter = func(time.Duration) <-chan time.Time {
|
||||||
|
ch := make(chan time.Time, 1)
|
||||||
|
ch <- time.Now()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { helperAfter = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQoderForwardStreamRemainingEdges(t *testing.T) {
|
||||||
|
installImmediateQoderAfter(t)
|
||||||
|
boom := errors.New("write failed")
|
||||||
|
f := activeQoderForwarder(qoderErrorWriteCloser{err: boom})
|
||||||
|
if _, err := f.forwardStream(context.Background(), "conv", "text", nil); err == nil {
|
||||||
|
t.Fatal("write error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control request, assistant delta and empty final fall back to accumulated text.
|
||||||
|
stdin := &qoderTestWriteCloser{}
|
||||||
|
f = activeQoderForwarder(stdin)
|
||||||
|
f.lines <- `{"type":"control_request","request_id":"req","request":{"type":"permission"}}`
|
||||||
|
f.lines <- `{"type":"assistant","message":{"content":[{"type":"text","text":"delta"}]}}`
|
||||||
|
f.lines <- `{"type":"result","subtype":"success"}`
|
||||||
|
var deltas []string
|
||||||
|
reply, err := f.forwardStream(context.Background(), "conv", "text", func(delta string) { deltas = append(deltas, delta) })
|
||||||
|
if err != nil || !strings.Contains(reply, "delta") || len(deltas) != 1 {
|
||||||
|
t.Fatalf("reply=%q deltas=%v err=%v", reply, deltas, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend error resets a remembered conversation.
|
||||||
|
sessions := newConvSessions("")
|
||||||
|
oldID := sessions.id("conv")
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.sessions = sessions
|
||||||
|
f.lines <- `{"type":"result","subtype":"error","error":"API Error: failed"}`
|
||||||
|
if reply, err := f.forwardStream(context.Background(), "conv", "text", nil); err != nil || !strings.Contains(reply, "failed") {
|
||||||
|
t.Fatalf("backend reply=%q err=%v", reply, err)
|
||||||
|
}
|
||||||
|
if sessions.id("conv") == oldID {
|
||||||
|
t.Fatal("backend error did not reset session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A completed result with no result text and no deltas returns the no-text hint.
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.lines <- `{"type":"result","subtype":"success"}`
|
||||||
|
if reply, err := f.forwardStream(context.Background(), "conv", "text", nil); err != nil || reply != "(本地 agent 无文本输出)" {
|
||||||
|
t.Fatalf("empty reply=%q err=%v", reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closed output is a read failure and clears remembered state.
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.sessions = sessions
|
||||||
|
close(f.lines)
|
||||||
|
if _, err := f.forwardStream(context.Background(), "conv", "text", nil); err == nil {
|
||||||
|
t.Fatal("closed output returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQoderEnsureLockedRemainingEdges(t *testing.T) {
|
||||||
|
origExec := qoderExecCommand
|
||||||
|
origAbs := qoderAbsPath
|
||||||
|
t.Cleanup(func() {
|
||||||
|
qoderExecCommand = origExec
|
||||||
|
qoderAbsPath = origAbs
|
||||||
|
})
|
||||||
|
boom := errors.New("abs")
|
||||||
|
qoderAbsPath = func(string) (string, error) { return "", boom }
|
||||||
|
if got := (&qoderStreamForwarder{workDir: "relative"}).cwd(); got != "relative" {
|
||||||
|
t.Fatalf("cwd fallback=%q", got)
|
||||||
|
}
|
||||||
|
qoderAbsPath = filepath.Abs
|
||||||
|
if got := (&qoderStreamForwarder{workDir: "."}).cwd(); !filepath.IsAbs(got) {
|
||||||
|
t.Fatalf("absolute cwd=%q", got)
|
||||||
|
}
|
||||||
|
if args := (&qoderStreamForwarder{model: "model"}).commandArgs(); !strings.Contains(strings.Join(args, " "), "--model model") {
|
||||||
|
t.Fatalf("model args=%v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
active := activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
if err := active.ensureLocked(context.Background()); err != nil {
|
||||||
|
t.Fatalf("active ensure: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A finished process is cleared and restart is attempted. Cover error/no-error
|
||||||
|
// exits and stderr/no-stderr diagnostics with a deterministic start failure.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
err error
|
||||||
|
stderr string
|
||||||
|
}{{errors.New("exit"), "detail"}, {errors.New("exit"), ""}, {nil, ""}} {
|
||||||
|
f := activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.done = make(chan error, 1)
|
||||||
|
f.done <- tc.err
|
||||||
|
_, _ = f.stderr.Write([]byte(tc.stderr))
|
||||||
|
qoderExecCommand = func(string, ...string) *exec.Cmd { return exec.Command(filepath.Join(t.TempDir(), "missing")) }
|
||||||
|
if err := f.ensureLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("restart failure returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StdinPipe and StdoutPipe reject commands whose streams are preconfigured.
|
||||||
|
qoderExecCommand = func(string, ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command("sh", "-c", "exit 0")
|
||||||
|
cmd.Stdin = strings.NewReader("")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
if err := (&qoderStreamForwarder{name: "qoder"}).ensureLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("stdin pipe error returned nil")
|
||||||
|
}
|
||||||
|
qoderExecCommand = func(string, ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command("sh", "-c", "exit 0")
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
if err := (&qoderStreamForwarder{name: "qoder"}).ensureLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("stdout pipe error returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQoderInitializeAndIOEdges(t *testing.T) {
|
||||||
|
installImmediateQoderAfter(t)
|
||||||
|
origUUID := qoderUUIDString
|
||||||
|
qoderUUIDString = func() string { return "fixed" }
|
||||||
|
t.Cleanup(func() { qoderUUIDString = origUUID })
|
||||||
|
boom := errors.New("write")
|
||||||
|
f := &qoderStreamForwarder{name: "qoder", stdin: qoderErrorWriteCloser{err: boom}}
|
||||||
|
if err := f.initializeLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("initialize write error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
close(f.lines)
|
||||||
|
if err := f.initializeLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("initialize read error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.lines <- `{"type":"control_request","request_id":"req","request":{"subtype":"permission"}}`
|
||||||
|
f.lines <- `{"type":"control_response","response":{"subtype":"success","request_id":"dws_init_fixed"}}`
|
||||||
|
if err := f.initializeLocked(context.Background()); err != nil {
|
||||||
|
t.Fatalf("initialize after control request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.lines <- `{"type":"control_response","response":{"subtype":"error","request_id":"wrong","error":"ignored"}}`
|
||||||
|
// The generated request ID is unknown, so cancel after covering the control and
|
||||||
|
// unmatched-response paths.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := f.initializeLocked(ctx); err == nil {
|
||||||
|
t.Fatal("cancelled initialize returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.lines <- `{"type":"control_response","response":{"subtype":"error","request_id":"dws_init_fixed","error":"initialize failed"}}`
|
||||||
|
if err := f.initializeLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("initialize response error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := (&qoderStreamForwarder{stdin: &qoderTestWriteCloser{}}).writeJSONLocked(make(chan int)); err == nil {
|
||||||
|
t.Fatal("marshal error returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLine skips noise, handles closed output with/without stderr, process
|
||||||
|
// exits with/without errors, and context cancellation.
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.lines <- ""
|
||||||
|
f.lines <- "noise"
|
||||||
|
f.lines <- `{"ok":true}`
|
||||||
|
if line, err := f.readLineLocked(context.Background()); err != nil || line == "" {
|
||||||
|
t.Fatalf("read JSON line=%q err=%v", line, err)
|
||||||
|
}
|
||||||
|
for _, stderr := range []string{"", "stderr detail"} {
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
_, _ = f.stderr.Write([]byte(stderr))
|
||||||
|
close(f.lines)
|
||||||
|
if _, err := f.readLineLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("closed lines returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, processErr := range []error{errors.New("exit"), nil} {
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
f.done = make(chan error, 1)
|
||||||
|
f.done <- processErr
|
||||||
|
if _, err := f.readLineLocked(context.Background()); err == nil {
|
||||||
|
t.Fatal("process exit returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f = activeQoderForwarder(&qoderTestWriteCloser{})
|
||||||
|
cancelled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := f.readLineLocked(cancelled); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancel error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQoderControlAndParseRemainingEdges(t *testing.T) {
|
||||||
|
f := &qoderStreamForwarder{stdin: &qoderTestWriteCloser{}}
|
||||||
|
for _, line := range []string{
|
||||||
|
`{`, `{"type":"other"}`, `{"type":"control_request"}`,
|
||||||
|
} {
|
||||||
|
if f.handleControlRequestLocked(line) {
|
||||||
|
t.Fatalf("unexpected handled line %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !f.handleControlRequestLocked(`{"type":"control_request","request_id":"req","request":{"type":"permission"}}`) {
|
||||||
|
t.Fatal("type fallback control not handled")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
line, request string
|
||||||
|
matched bool
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{`{`, "req", false, false},
|
||||||
|
{`{"type":"other"}`, "req", false, false},
|
||||||
|
{`{"type":"control_response","response":{"request_id":"other"}}`, "req", false, false},
|
||||||
|
{`{"type":"control_response","response":{"request_id":"req","subtype":"error"}}`, "req", true, true},
|
||||||
|
{`{"type":"control_response","response":{"request_id":"req","subtype":"error","error":"failed"}}`, "req", true, true},
|
||||||
|
{`{"type":"control_response","response":{"request_id":"req","subtype":"success"}}`, "req", true, false},
|
||||||
|
} {
|
||||||
|
matched, err := qoderControlResponse(tc.line, tc.request)
|
||||||
|
if matched != tc.matched || (err != nil) != tc.err {
|
||||||
|
t.Fatalf("control(%q)=(%v,%v)", tc.line, matched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range []string{
|
||||||
|
`{`,
|
||||||
|
`{"type":"assistant","message":{"content":[{"type":"tool","text":"hidden"}]}}`,
|
||||||
|
`{"type":"assistant","message":{"content":[{"type":"text","text":"visible"}]}}`,
|
||||||
|
`{"type":"result","subtype":"error","error":"failed"}`,
|
||||||
|
`{"type":"result","subtype":"error","message":{"content":[{"type":"text","text":"fallback"}]}}`,
|
||||||
|
`{"type":"result","subtype":"success","message":{"content":[{"type":"text","text":"done"}]}}`,
|
||||||
|
`{"type":"result","subtype":"success","result":"result"}`,
|
||||||
|
`{"type":"error","error":"failed"}`,
|
||||||
|
`{"type":"error"}`,
|
||||||
|
`{"type":"other"}`,
|
||||||
|
} {
|
||||||
|
_, _, _ = parseQoderPersistentLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQoderCloseAndScanEdges(t *testing.T) {
|
||||||
|
installImmediateQoderAfter(t)
|
||||||
|
closer := &qoderCloseRecorder{}
|
||||||
|
f := &qoderStreamForwarder{stdin: closer, done: make(chan error)}
|
||||||
|
if err := f.close(); err != nil || !closer.closed {
|
||||||
|
t.Fatalf("close err=%v closed=%v", err, closer.closed)
|
||||||
|
}
|
||||||
|
lines := make(chan string, 2)
|
||||||
|
scanQoderLines(bytes.NewBufferString("one\ntwo\n"), lines)
|
||||||
|
var got []string
|
||||||
|
for line := range lines {
|
||||||
|
got = append(got, line)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != "one,two" {
|
||||||
|
t.Fatalf("scanned=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,21 @@ import (
|
|||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type connectSessionTempFile interface {
|
||||||
|
Name() string
|
||||||
|
Chmod(os.FileMode) error
|
||||||
|
Write([]byte) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
connectSessionReadFile = os.ReadFile
|
||||||
|
connectSessionMkdirAll = os.MkdirAll
|
||||||
|
connectSessionCreateTemp = func(dir, pattern string) (connectSessionTempFile, error) { return os.CreateTemp(dir, pattern) }
|
||||||
|
connectSessionRename = os.Rename
|
||||||
|
connectSessionRemove = os.Remove
|
||||||
|
)
|
||||||
|
|
||||||
// connectSessionStorePath returns the on-disk location for a robot's
|
// connectSessionStorePath returns the on-disk location for a robot's
|
||||||
// conversation→session map, scoped by clientId so multiple bots on one machine
|
// conversation→session map, scoped by clientId so multiple bots on one machine
|
||||||
// stay isolated: <config dir>/connect/<clientId>/sessions.json. An empty
|
// stay isolated: <config dir>/connect/<clientId>/sessions.json. An empty
|
||||||
@@ -44,7 +59,7 @@ func loadConvSessionMap(path string) map[string]string {
|
|||||||
if path == "" {
|
if path == "" {
|
||||||
return make(map[string]string)
|
return make(map[string]string)
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := connectSessionReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 读取会话存档失败,按空会话起:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 读取会话存档失败,按空会话起:%v\n", err)
|
||||||
@@ -70,17 +85,12 @@ func saveConvSessionMap(path string, m map[string]string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
if err := connectSessionMkdirAll(dir, 0o700); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话存档目录失败,跳过本次落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话存档目录失败,跳过本次落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(m)
|
data, _ := json.Marshal(m)
|
||||||
if err != nil {
|
tmp, err := connectSessionCreateTemp(dir, "sessions-*.json.tmp")
|
||||||
// map[string]string never fails to marshal, but stay defensive.
|
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 序列化会话失败,跳过本次落盘:%v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(dir, "sessions-*.json.tmp")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话临时文件失败,跳过本次落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话临时文件失败,跳过本次落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
@@ -88,23 +98,23 @@ func saveConvSessionMap(path string, m map[string]string) {
|
|||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
if err := tmp.Chmod(config.FilePerm); err != nil {
|
if err := tmp.Chmod(config.FilePerm); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
_ = os.Remove(tmpName)
|
_ = connectSessionRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 设置会话文件权限失败,跳过本次落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 设置会话文件权限失败,跳过本次落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := tmp.Write(data); err != nil {
|
if _, err := tmp.Write(data); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
_ = os.Remove(tmpName)
|
_ = connectSessionRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 写入会话临时文件失败,跳过本次落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 写入会话临时文件失败,跳过本次落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
_ = os.Remove(tmpName)
|
_ = connectSessionRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 关闭会话临时文件失败,跳过本次落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 关闭会话临时文件失败,跳过本次落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmpName, path); err != nil {
|
if err := connectSessionRename(tmpName, path); err != nil {
|
||||||
_ = os.Remove(tmpName)
|
_ = connectSessionRemove(tmpName)
|
||||||
fmt.Fprintf(os.Stderr, "[connect][session][warn] 原子替换会话存档失败,跳过本次落盘:%v\n", err)
|
fmt.Fprintf(os.Stderr, "[connect][session][warn] 原子替换会话存档失败,跳过本次落盘:%v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type failingSessionTempFile struct {
|
||||||
|
chmodErr error
|
||||||
|
writeErr error
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*failingSessionTempFile) Name() string { return "sessions.tmp" }
|
||||||
|
func (f *failingSessionTempFile) Chmod(os.FileMode) error { return f.chmodErr }
|
||||||
|
func (f *failingSessionTempFile) Write(p []byte) (int, error) {
|
||||||
|
if f.writeErr != nil {
|
||||||
|
return 0, f.writeErr
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
func (f *failingSessionTempFile) Close() error { return f.closeErr }
|
||||||
|
|
||||||
|
func TestConnectSessionStoreFailureCoverage(t *testing.T) {
|
||||||
|
originalRead := connectSessionReadFile
|
||||||
|
originalMkdir := connectSessionMkdirAll
|
||||||
|
originalCreate := connectSessionCreateTemp
|
||||||
|
originalRename := connectSessionRename
|
||||||
|
originalRemove := connectSessionRemove
|
||||||
|
t.Cleanup(func() {
|
||||||
|
connectSessionReadFile = originalRead
|
||||||
|
connectSessionMkdirAll = originalMkdir
|
||||||
|
connectSessionCreateTemp = originalCreate
|
||||||
|
connectSessionRename = originalRename
|
||||||
|
connectSessionRemove = originalRemove
|
||||||
|
})
|
||||||
|
boom := errors.New("session store failure")
|
||||||
|
|
||||||
|
connectSessionReadFile = func(string) ([]byte, error) { return nil, boom }
|
||||||
|
if got := loadConvSessionMap("sessions.json"); len(got) != 0 {
|
||||||
|
t.Fatalf("read failure map = %#v", got)
|
||||||
|
}
|
||||||
|
connectSessionReadFile = originalRead
|
||||||
|
|
||||||
|
connectSessionMkdirAll = func(string, os.FileMode) error { return boom }
|
||||||
|
saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"})
|
||||||
|
connectSessionMkdirAll = func(string, os.FileMode) error { return nil }
|
||||||
|
connectSessionCreateTemp = func(string, string) (connectSessionTempFile, error) { return nil, boom }
|
||||||
|
saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"})
|
||||||
|
|
||||||
|
connectSessionRemove = func(string) error { return nil }
|
||||||
|
for _, file := range []*failingSessionTempFile{
|
||||||
|
{chmodErr: boom},
|
||||||
|
{writeErr: boom},
|
||||||
|
{closeErr: boom},
|
||||||
|
} {
|
||||||
|
connectSessionCreateTemp = func(string, string) (connectSessionTempFile, error) { return file, nil }
|
||||||
|
saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"})
|
||||||
|
}
|
||||||
|
connectSessionCreateTemp = func(string, string) (connectSessionTempFile, error) { return &failingSessionTempFile{}, nil }
|
||||||
|
connectSessionRename = func(string, string) error { return boom }
|
||||||
|
saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"})
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ type connectChatReplier interface {
|
|||||||
|
|
||||||
type connectMediaClient interface {
|
type connectMediaClient interface {
|
||||||
downloadMessageFile(context.Context, string, string) (string, error)
|
downloadMessageFile(context.Context, string, string) (string, error)
|
||||||
|
downloadMessageFileNamed(context.Context, string, string, string) (string, error)
|
||||||
|
downloadRecoveredChatRecordFile(context.Context, fileInboundInfo) (string, error)
|
||||||
getUserUnionID(context.Context, string) (string, error)
|
getUserUnionID(context.Context, string) (string, error)
|
||||||
downloadDentryFile(context.Context, int64, int64, string, string) (string, error)
|
downloadDentryFile(context.Context, int64, int64, string, string) (string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ func (m *fakeConnectMedia) downloadMessageFile(context.Context, string, string)
|
|||||||
return m.messagePath, m.messageErr
|
return m.messagePath, m.messageErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *fakeConnectMedia) downloadMessageFileNamed(context.Context, string, string, string) (string, error) {
|
||||||
|
return m.messagePath, m.messageErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeConnectMedia) downloadRecoveredChatRecordFile(context.Context, fileInboundInfo) (string, error) {
|
||||||
|
return m.messagePath, m.messageErr
|
||||||
|
}
|
||||||
|
|
||||||
func (m *fakeConnectMedia) getUserUnionID(context.Context, string) (string, error) {
|
func (m *fakeConnectMedia) getUserUnionID(context.Context, string) (string, error) {
|
||||||
return m.unionID, m.unionErr
|
return m.unionID, m.unionErr
|
||||||
}
|
}
|
||||||
@@ -242,14 +250,14 @@ func runConnectorScenarioWithCard(t *testing.T, messages []*chatbot.BotCallbackD
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunStreamConnectorBasicMessageEdges(t *testing.T) {
|
func TestRunStreamConnectorBasicMessageEdges(t *testing.T) {
|
||||||
t.Run("blank and missing webhook drop", func(t *testing.T) {
|
t.Run("blank fallback and missing webhook drop", func(t *testing.T) {
|
||||||
fwd := &basicConnectorForwarder{reply: "unused"}
|
fwd := &basicConnectorForwarder{reply: "unused"}
|
||||||
blank := connectorMessage("blank", "")
|
blank := connectorMessage("blank", "")
|
||||||
noWebhook := connectorMessage("webhook", "hello")
|
noWebhook := connectorMessage("webhook", "hello")
|
||||||
noWebhook.SessionWebhook = ""
|
noWebhook.SessionWebhook = ""
|
||||||
runConnectorScenario(t, []*chatbot.BotCallbackDataModel{blank, noWebhook}, fwd, nil, nil, nil, 0)
|
runConnectorScenario(t, []*chatbot.BotCallbackDataModel{blank, noWebhook}, fwd, nil, nil, nil, 0)
|
||||||
if len(fwd.prompts) != 0 {
|
if len(fwd.prompts) != 1 || !strings.Contains(fwd.prompts[0], "原始消息 JSON") {
|
||||||
t.Fatalf("dropped messages forwarded: %#v", fwd.prompts)
|
t.Fatalf("fallback messages forwarded: %#v", fwd.prompts)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -533,7 +541,7 @@ func TestRunStreamConnectorMediaEdges(t *testing.T) {
|
|||||||
m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
|
m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
|
||||||
return m
|
return m
|
||||||
}(),
|
}(),
|
||||||
media: &fakeConnectMedia{messagePath: "/tmp/report.txt"}, want: "用户发来一个文件",
|
media: &fakeConnectMedia{messagePath: "/tmp/report.txt"}, want: "/tmp/report.txt",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "client file failure",
|
name: "client file failure",
|
||||||
@@ -543,7 +551,7 @@ func TestRunStreamConnectorMediaEdges(t *testing.T) {
|
|||||||
m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
|
m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"}
|
||||||
return m
|
return m
|
||||||
}(),
|
}(),
|
||||||
media: &fakeConnectMedia{messageErr: errors.New("download")}, want: "文件下载失败",
|
media: &fakeConnectMedia{messageErr: errors.New("download")}, want: "原始内容下载失败",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dentry success",
|
name: "dentry success",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var connectStreamCommandContext = exec.CommandContext
|
||||||
|
|
||||||
// Incremental forwarding: channels whose CLI can emit stream-json get their
|
// Incremental forwarding: channels whose CLI can emit stream-json get their
|
||||||
// reply streamed into the AI card as it is produced (the hermes/openclaw
|
// reply streamed into the AI card as it is produced (the hermes/openclaw
|
||||||
// experience), instead of one shot at the end.
|
// experience), instead of one shot at the end.
|
||||||
@@ -54,7 +56,7 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string,
|
|||||||
|
|
||||||
run := func() (string, string, error) {
|
run := func() (string, string, error) {
|
||||||
args := f.commandArgs(f.streamArgv, convID, text)
|
args := f.commandArgs(f.streamArgv, convID, text)
|
||||||
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
|
cmd := connectStreamCommandContext(ctx, f.streamArgv[0], args...)
|
||||||
f.configureCommand(cmd)
|
f.configureCommand(cmd)
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeStreamingAgent(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "agent")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamingExecForwarder(bin string) *execForwarder {
|
||||||
|
return &execForwarder{
|
||||||
|
name: "claudecode",
|
||||||
|
argv: []string{bin},
|
||||||
|
streamArgv: []string{bin},
|
||||||
|
parser: "cc",
|
||||||
|
timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecForwarderStreamingRemainingCoverage(t *testing.T) {
|
||||||
|
// Streaming-disabled forwarders use their ordinary one-shot command.
|
||||||
|
oneShot := writeStreamingAgent(t, `printf 'one-shot\n'`)
|
||||||
|
f := streamingExecForwarder(oneShot)
|
||||||
|
f.streamArgv = nil
|
||||||
|
if reply, err := f.forwardStream(context.Background(), "", "prompt", func(string) {}); err != nil || reply != "one-shot" {
|
||||||
|
t.Fatalf("fallback reply=%q err=%v", reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StdoutPipe failures are surfaced as invocation failures.
|
||||||
|
origCommand := connectStreamCommandContext
|
||||||
|
connectStreamCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
if _, err := streamingExecForwarder(oneShot).forwardStream(context.Background(), "", "prompt", func(string) {}); err == nil {
|
||||||
|
t.Fatal("StdoutPipe failure returned nil")
|
||||||
|
}
|
||||||
|
connectStreamCommandContext = origCommand
|
||||||
|
t.Cleanup(func() { connectStreamCommandContext = origCommand })
|
||||||
|
|
||||||
|
// A missing executable reaches the process-start failure path.
|
||||||
|
if _, err := streamingExecForwarder(filepath.Join(t.TempDir(), "missing")).forwardStream(context.Background(), "", "prompt", func(string) {}); err == nil {
|
||||||
|
t.Fatal("start failure returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore blank/non-JSON lines, publish deltas, and turn a bare backend
|
||||||
|
// error into an actionable reply while clearing the stale session.
|
||||||
|
backend := writeStreamingAgent(t, `
|
||||||
|
printf '\nnoise\n'
|
||||||
|
printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}}'
|
||||||
|
printf '%s\n' '{"type":"result","result":"API Error: failed"}'`)
|
||||||
|
sessions := newConvSessions("")
|
||||||
|
oldID := sessions.id("conv")
|
||||||
|
f = streamingExecForwarder(backend)
|
||||||
|
f.sessions = sessions
|
||||||
|
var deltas []string
|
||||||
|
reply, err := f.forwardStream(context.Background(), "conv", "prompt", func(s string) { deltas = append(deltas, s) })
|
||||||
|
if err != nil || !strings.Contains(reply, "failed") || len(deltas) != 1 || !strings.Contains(deltas[0], "partial") {
|
||||||
|
t.Fatalf("backend reply=%q deltas=%v err=%v", reply, deltas, err)
|
||||||
|
}
|
||||||
|
if sessions.id("conv") == oldID {
|
||||||
|
t.Fatal("backend error did not reset session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful command without visible events returns the no-text hint.
|
||||||
|
empty := writeStreamingAgent(t, `printf 'metadata\n'`)
|
||||||
|
if reply, err := streamingExecForwarder(empty).forwardStream(context.Background(), "", "prompt", func(string) {}); err != nil || reply != "(本地 agent 无文本输出)" {
|
||||||
|
t.Fatalf("empty reply=%q err=%v", reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-session process failure prefers stderr and reports the final error.
|
||||||
|
failure := writeStreamingAgent(t, `echo 'plain failure' >&2; exit 1`)
|
||||||
|
if _, err := streamingExecForwarder(failure).forwardStream(context.Background(), "", "prompt", func(string) {}); err == nil || !strings.Contains(err.Error(), "plain failure") {
|
||||||
|
t.Fatalf("plain failure err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failing remembered session is cleared even when it is not retryable.
|
||||||
|
sessions = newConvSessions("")
|
||||||
|
oldID = sessions.id("conv")
|
||||||
|
f = streamingExecForwarder(failure)
|
||||||
|
f.sessions = sessions
|
||||||
|
if _, err := f.forwardStream(context.Background(), "conv", "prompt", func(string) {}); err == nil {
|
||||||
|
t.Fatal("session failure returned nil")
|
||||||
|
}
|
||||||
|
if sessions.id("conv") == oldID {
|
||||||
|
t.Fatal("terminal stream failure did not reset session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStreamLineCCInvalidJSON(t *testing.T) {
|
||||||
|
if delta, final := parseStreamLine("cc", `{not json`); delta != "" || final != "" {
|
||||||
|
t.Fatalf("invalid cc JSON delta=%q final=%q", delta, final)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -600,9 +600,6 @@ contact user profile fields 获取可用字段列表。
|
|||||||
{contactDeptListMembersCmd, []string{"ids", "id", "dept-id", "dept-ids"}},
|
{contactDeptListMembersCmd, []string{"ids", "id", "dept-id", "dept-ids"}},
|
||||||
} {
|
} {
|
||||||
for _, name := range s.aliases {
|
for _, name := range s.aliases {
|
||||||
if s.cmd.Flags().Lookup(name) != nil {
|
|
||||||
continue // 已是主 flag,跳过
|
|
||||||
}
|
|
||||||
s.cmd.Flags().String(name, "", "部门 ID 别名(等价于当前命令的主 flag)")
|
s.cmd.Flags().String(name, "", "部门 ID 别名(等价于当前命令的主 flag)")
|
||||||
_ = s.cmd.Flags().MarkHidden(name)
|
_ = s.cmd.Flags().MarkHidden(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContactRemainingCompatibilityBranches(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "flags"}
|
||||||
|
cmd.Flags().String("id", "", "")
|
||||||
|
if got := contactFirstSetFlagName(cmd, "id"); got != "id" {
|
||||||
|
t.Fatalf("default flag name=%q", got)
|
||||||
|
}
|
||||||
|
if got := contactFirstSetFlagName(cmd); got != "" {
|
||||||
|
t.Fatalf("empty flag name=%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"user", "get", "--ids", "me"},
|
||||||
|
{"get", "--ids", "self"},
|
||||||
|
} {
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
if err := executeFilterCoverage(t, newContactCommand(), args...); err == nil || !strings.Contains(err.Error(), "真实的 userId") {
|
||||||
|
t.Fatalf("args=%v placeholder err=%v", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
if err := executeFilterCoverage(t, newContactCommand(), "get", "--dept", "1"); err != nil {
|
||||||
|
t.Fatalf("compat dept get: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := newContactCommand()
|
||||||
|
hint := findCoverageSubcommand(t, root, "department")
|
||||||
|
if err := hint.RunE(hint, []string{"--help"}); err != nil {
|
||||||
|
t.Fatalf("hint help: %v", err)
|
||||||
|
}
|
||||||
|
if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") {
|
||||||
|
t.Fatalf("hint guidance err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") {
|
||||||
|
t.Fatalf("flag help hint err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,12 +75,6 @@ func TestPureScalarAndCommandHelpersCoverage(t *testing.T) {
|
|||||||
for _, value := range []any{json.Number("1"), json.Number("bad"), float64(2), float64(-1), int64(3), 4, "5", "bad", true} {
|
for _, value := range []any{json.Number("1"), json.Number("bad"), float64(2), float64(-1), int64(3), 4, "5", "bad", true} {
|
||||||
_, _ = int64FromJSONScalar(value)
|
_, _ = int64FromJSONScalar(value)
|
||||||
}
|
}
|
||||||
if err := printMCPText(`{"ok":true}`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := printMCPText("not-json"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChartMailAndSheetPureHelpersCoverage(t *testing.T) {
|
func TestChartMailAndSheetPureHelpersCoverage(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeDevAppEdge(runner executor.Runner, args ...string) (string, error) {
|
||||||
|
root := newDevAppTestRoot(runner)
|
||||||
|
var out bytes.Buffer
|
||||||
|
root.SetOut(&out)
|
||||||
|
root.SetErr(&out)
|
||||||
|
root.SetArgs(append([]string{"dev", "app"}, args...))
|
||||||
|
err := root.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppParentHelpCoverage(t *testing.T) {
|
||||||
|
for _, path := range [][]string{
|
||||||
|
{}, {"webapp"}, {"permission"}, {"credentials"}, {"member"},
|
||||||
|
{"security"}, {"robot"}, {"version"}, {"event"},
|
||||||
|
} {
|
||||||
|
if _, err := executeDevAppEdge(&captureRunner{}, path...); err != nil {
|
||||||
|
t.Fatalf("help %v: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppValidationBranchesCoverage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{"event list id", []string{"event", "list"}},
|
||||||
|
{"event subscribe guard", []string{"event", "subscribe"}},
|
||||||
|
{"event subscribe id", []string{"event", "subscribe", "--dry-run"}},
|
||||||
|
{"event subscribe codes", []string{"event", "subscribe", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"event unsubscribe guard", []string{"event", "unsubscribe"}},
|
||||||
|
{"event unsubscribe id", []string{"event", "unsubscribe", "--dry-run"}},
|
||||||
|
{"event unsubscribe codes", []string{"event", "unsubscribe", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"get id", []string{"get"}},
|
||||||
|
{"create name", []string{"create", "--dry-run"}},
|
||||||
|
{"update guard", []string{"update"}},
|
||||||
|
{"update id", []string{"update", "--dry-run"}},
|
||||||
|
{"update fields", []string{"update", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"credentials id", []string{"credentials", "get"}},
|
||||||
|
{"disable guard", []string{"disable"}},
|
||||||
|
{"enable id", []string{"enable", "--dry-run"}},
|
||||||
|
{"delete guard", []string{"delete"}},
|
||||||
|
{"delete id", []string{"delete", "--dry-run"}},
|
||||||
|
{"delete confirm", []string{"delete", "--yes", "--unified-app-id", "app"}},
|
||||||
|
{"webapp get id", []string{"webapp", "get"}},
|
||||||
|
{"webapp config guard", []string{"webapp", "config"}},
|
||||||
|
{"webapp config id", []string{"webapp", "config", "--dry-run"}},
|
||||||
|
{"webapp config fields", []string{"webapp", "config", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"permission list id", []string{"permission", "list"}},
|
||||||
|
{"permission add guard", []string{"permission", "add"}},
|
||||||
|
{"permission add id", []string{"permission", "add", "--dry-run"}},
|
||||||
|
{"permission add scopes", []string{"permission", "add", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"permission remove guard", []string{"permission", "remove"}},
|
||||||
|
{"permission remove id", []string{"permission", "remove", "--dry-run"}},
|
||||||
|
{"permission remove scopes", []string{"permission", "remove", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"member list id", []string{"member", "list"}},
|
||||||
|
{"member add guard", []string{"member", "add"}},
|
||||||
|
{"member add id", []string{"member", "add", "--dry-run"}},
|
||||||
|
{"security guard", []string{"security", "config"}},
|
||||||
|
{"security id", []string{"security", "config", "--dry-run"}},
|
||||||
|
{"security fields", []string{"security", "config", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"robot submit guard", []string{"robot", "submit"}},
|
||||||
|
{"robot submit params", []string{"robot", "submit", "--dry-run"}},
|
||||||
|
{"robot result task", []string{"robot", "result"}},
|
||||||
|
{"robot get id", []string{"robot", "get"}},
|
||||||
|
{"robot config guard", []string{"robot", "config"}},
|
||||||
|
{"robot config id", []string{"robot", "config", "--dry-run"}},
|
||||||
|
{"robot config fields", []string{"robot", "config", "--dry-run", "--unified-app-id", "app"}},
|
||||||
|
{"robot enable guard", []string{"robot", "enable"}},
|
||||||
|
{"robot enable id", []string{"robot", "enable", "--dry-run"}},
|
||||||
|
{"robot disable guard", []string{"robot", "disable"}},
|
||||||
|
{"robot disable id", []string{"robot", "disable", "--dry-run"}},
|
||||||
|
{"version create id", []string{"version", "create", "--dry-run"}},
|
||||||
|
{"version list id", []string{"version", "list"}},
|
||||||
|
{"version get app", []string{"version", "get"}},
|
||||||
|
{"version get version", []string{"version", "get", "--unified-app-id", "app"}},
|
||||||
|
{"version approval app", []string{"version", "check-approval"}},
|
||||||
|
{"version publish guard", []string{"version", "publish"}},
|
||||||
|
{"version publish app", []string{"version", "publish", "--dry-run"}},
|
||||||
|
{"version status app", []string{"version", "status"}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := executeDevAppEdge(&captureRunner{}, tc.args...); err == nil {
|
||||||
|
t.Fatalf("Execute(%v) returned nil", tc.args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppOptionalMutationBranchesCoverage(t *testing.T) {
|
||||||
|
success := [][]string{
|
||||||
|
{"update", "--dry-run", "--unified-app-id", "app", "--icon-media-id", "media"},
|
||||||
|
{"delete", "--dry-run", "--unified-app-id", "app"},
|
||||||
|
{"webapp", "config", "--dry-run", "--unified-app-id", "app", "--h5-page-type", "HOME", "--omp-url", "https://omp.invalid"},
|
||||||
|
{"robot", "config", "--dry-run", "--unified-app-id", "app", "--disable-ssl-verify", "--i18n-name", `{"zh_CN":"助手"}`},
|
||||||
|
}
|
||||||
|
for _, args := range success {
|
||||||
|
if _, err := executeDevAppEdge(&captureRunner{}, args...); err != nil {
|
||||||
|
t.Fatalf("Execute(%v): %v", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := executeDevAppEdge(&captureRunner{}, "robot", "config", "--dry-run", "--unified-app-id", "app", "--mode", "bad"); err == nil {
|
||||||
|
t.Fatal("invalid robot mode returned nil")
|
||||||
|
}
|
||||||
|
if _, err := executeDevAppEdge(&captureRunner{}, "robot", "config", "--dry-run", "--unified-app-id", "app", "--i18n-brief", "{"); err == nil {
|
||||||
|
t.Fatal("invalid i18n JSON returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppRobotCreateRequiredFieldsCoverage(t *testing.T) {
|
||||||
|
cmd := newDevAppRobotSubmitCommand(&captureRunner{})
|
||||||
|
if _, err := devAppRobotCreateParams(cmd); err == nil {
|
||||||
|
t.Fatal("missing name returned nil")
|
||||||
|
}
|
||||||
|
_ = cmd.Flags().Set("name", "app")
|
||||||
|
if _, err := devAppRobotCreateParams(cmd); err == nil {
|
||||||
|
t.Fatal("missing robot name returned nil")
|
||||||
|
}
|
||||||
|
_ = cmd.Flags().Set("robot-name", "robot")
|
||||||
|
if _, err := devAppRobotCreateParams(cmd); err == nil {
|
||||||
|
t.Fatal("missing desc returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppDeleteFetchErrorCoverage(t *testing.T) {
|
||||||
|
want := errors.New("fetch app failed")
|
||||||
|
_, err := executeDevAppEdge(devAppErrorRunner{err: want}, "delete", "--yes", "--unified-app-id", "app", "--confirm-name", "name")
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("delete error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppPrettyAndAnnotationEdges(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
annotateDevAppTool(cmd, "tool")
|
||||||
|
if cmd.Annotations["mcp-tool"] != "tool" {
|
||||||
|
t.Fatalf("annotations = %#v", cmd.Annotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretty output takes a separate annotation path after normalization.
|
||||||
|
runner := &devAppResponseRunner{response: map[string]any{
|
||||||
|
"content": map[string]any{"success": true, "result": map[string]any{"id": "app"}},
|
||||||
|
}}
|
||||||
|
out, err := executeDevAppEdge(runner, "list", "--format", "pretty")
|
||||||
|
if err != nil || !strings.Contains(out, "app") {
|
||||||
|
t.Fatalf("pretty list err=%v out=%q", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,17 @@ var connectChannels = func() map[string]struct{} {
|
|||||||
return m
|
return m
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// These indirections keep the command's process/network boundaries testable.
|
||||||
|
// Production always uses the real implementations; unit tests replace only the
|
||||||
|
// final side-effecting step while exercising the full option/validation flow.
|
||||||
|
var (
|
||||||
|
devAppRunStreamConnector = runStreamConnector
|
||||||
|
devAppLoadConnectKnowledge = loadConnectKnowledgeSource
|
||||||
|
devAppConnectStdinInteractive = connectStdinInteractive
|
||||||
|
devAppRunConnectOnboarding = runConnectOnboarding
|
||||||
|
devAppStartConnectDaemon = startDaemon
|
||||||
|
)
|
||||||
|
|
||||||
// resolveConnectChannel resolves the current agent channel using "explicit wins,
|
// resolveConnectChannel resolves the current agent channel using "explicit wins,
|
||||||
// then signal fallback". Priority: --channel flag > DWS_AGENT_CHANNEL env var >
|
// then signal fallback". Priority: --channel flag > DWS_AGENT_CHANNEL env var >
|
||||||
// each agent's known runtime signal. Returns the channel name and the basis for
|
// each agent's known runtime signal. Returns the channel name and the basis for
|
||||||
@@ -284,7 +295,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client
|
|||||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), opts.KnowledgeDir)
|
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), opts.KnowledgeDir)
|
||||||
}
|
}
|
||||||
if opts.KnowledgeSource != "" {
|
if opts.KnowledgeSource != "" {
|
||||||
if kb, kerr := loadConnectKnowledgeSource(cmd, runner, clientID, opts.KnowledgeSource); kerr != nil {
|
if kb, kerr := devAppLoadConnectKnowledge(cmd, runner, clientID, opts.KnowledgeSource); kerr != nil {
|
||||||
return kerr
|
return kerr
|
||||||
} else if kb != nil {
|
} else if kb != nil {
|
||||||
extras.kb = kb
|
extras.kb = kb
|
||||||
@@ -294,7 +305,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client
|
|||||||
// chunks into the same retriever (knowledgeBase is just a chunk slice), so
|
// chunks into the same retriever (knowledgeBase is just a chunk slice), so
|
||||||
// a role can carry several wiki/doc/dir sources without a flag per source.
|
// a role can carry several wiki/doc/dir sources without a flag per source.
|
||||||
for _, src := range opts.KnowledgeSources {
|
for _, src := range opts.KnowledgeSources {
|
||||||
kb, kerr := loadConnectKnowledgeSource(cmd, runner, clientID, src)
|
kb, kerr := devAppLoadConnectKnowledge(cmd, runner, clientID, src)
|
||||||
if kerr != nil {
|
if kerr != nil {
|
||||||
return kerr
|
return kerr
|
||||||
}
|
}
|
||||||
@@ -365,7 +376,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client
|
|||||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 提示:机器人默认在空白临时目录里跑、不带你本地项目的上下文,回答可能不如终端准。要对齐终端:加 --agent-workdir <你的项目目录> 让它读到同样的文件,或 --knowledge-dir/--knowledge-source 挂资料。\n")
|
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 提示:机器人默认在空白临时目录里跑、不带你本地项目的上下文,回答可能不如终端准。要对齐终端:加 --agent-workdir <你的项目目录> 让它读到同样的文件,或 --knowledge-dir/--knowledge-source 挂资料。\n")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s,回复样式=%s(Ctrl-C 退出)\n", channel, fwd.label(), replyStyle)
|
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s,回复样式=%s(Ctrl-C 退出)\n", channel, fwd.label(), replyStyle)
|
||||||
return runStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras)
|
return devAppRunStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hermes / openclaw run their own official bot provisioning + reply logic
|
// hermes / openclaw run their own official bot provisioning + reply logic
|
||||||
@@ -506,12 +517,12 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
|||||||
}
|
}
|
||||||
clientID, clientSecret = id, secret
|
clientID, clientSecret = id, secret
|
||||||
resolvedBy = "unified-app-id:credentials get"
|
resolvedBy = "unified-app-id:credentials get"
|
||||||
case !commandDryRun(cmd) && connectStdinInteractive():
|
case !commandDryRun(cmd) && devAppConnectStdinInteractive():
|
||||||
// No credentials and a real terminal: guide the user through
|
// No credentials and a real terminal: guide the user through
|
||||||
// provisioning a new robot app or picking an existing one,
|
// provisioning a new robot app or picking an existing one,
|
||||||
// instead of failing. Scripts/daemons/pipes and dry-run fall
|
// instead of failing. Scripts/daemons/pipes and dry-run fall
|
||||||
// through to the explicit-flag requirement below.
|
// through to the explicit-flag requirement below.
|
||||||
creds, oerr := runConnectOnboarding(runner, cmd, cmd.InOrStdin(), cmd.OutOrStdout())
|
creds, oerr := devAppRunConnectOnboarding(runner, cmd, cmd.InOrStdin(), cmd.OutOrStdout())
|
||||||
if oerr != nil {
|
if oerr != nil {
|
||||||
return oerr
|
return oerr
|
||||||
}
|
}
|
||||||
@@ -551,7 +562,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
|||||||
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
|
notifyStaffID := devAppStringFlag(cmd, "notify-staff-id")
|
||||||
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
|
profile, _ := cmd.Root().PersistentFlags().GetString("profile")
|
||||||
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
|
alwaysOn, _ := cmd.Flags().GetBool("alwayson")
|
||||||
return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn)
|
return devAppStartConnectDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
|
fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy)
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type connectCoverageRunner struct {
|
||||||
|
response map[string]any
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r connectCoverageRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||||
|
return executor.Result{Invocation: inv, Response: r.response}, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectCoverageCommand(t *testing.T) (*cobra.Command, *bytes.Buffer) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newDevAppRobotConnectCommand(&captureRunner{})
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
cmd.SetOut(buf)
|
||||||
|
cmd.SetErr(buf)
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
return cmd, buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKnowledgeFile(t *testing.T, dir, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("# handbook\ncovered knowledge"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildConnectPlanQoderAndOpencodeCoverage(t *testing.T) {
|
||||||
|
for _, ch := range []string{"qoder", "qoderwork", "opencode", "gemini"} {
|
||||||
|
if got := buildConnectPlan(ch, "id", "robot")["method"]; got == "unknown" {
|
||||||
|
t.Fatalf("buildConnectPlan(%q) = unknown", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchConnectorExternalAndGuidanceCoverage(t *testing.T) {
|
||||||
|
cmd, out := connectCoverageCommand(t)
|
||||||
|
binDir := t.TempDir()
|
||||||
|
writeShellExecutable(t, binDir, "connector-ok", "test \"$CID:$SEC:$DWS_AGENT_CHANNEL\" = \"id:secret:hermes\"\n")
|
||||||
|
writeShellExecutable(t, binDir, "connector-fail", "exit 7\n")
|
||||||
|
t.Setenv("DWS_CONNECT_CMD", filepath.Join(binDir, "connector-ok"))
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "hermes", "id", "secret", connectAgentOptions{}); err != nil {
|
||||||
|
t.Fatalf("external connector: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_CONNECT_CMD", filepath.Join(binDir, "connector-fail"))
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "hermes", "id", "secret", connectAgentOptions{}); err == nil {
|
||||||
|
t.Fatal("failing external connector returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DWS_CONNECT_CMD", "")
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "hermes", "id", "secret", connectAgentOptions{}); err != nil {
|
||||||
|
t.Fatalf("official guidance: %v", err)
|
||||||
|
}
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "unknown", "id", "secret", connectAgentOptions{}); err == nil {
|
||||||
|
t.Fatal("unknown connector returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchConnectorValidationAndKnowledgeCoverage(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONNECT_CMD", "")
|
||||||
|
t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok")
|
||||||
|
cmd, _ := connectCoverageCommand(t)
|
||||||
|
|
||||||
|
badRole := filepath.Join(t.TempDir(), "missing.yaml")
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{RoleConfigPath: badRole}); err == nil {
|
||||||
|
t.Fatal("missing role returned nil")
|
||||||
|
}
|
||||||
|
roleDir := t.TempDir()
|
||||||
|
rolePath := filepath.Join(roleDir, "role.yaml")
|
||||||
|
if err := os.WriteFile(rolePath, []byte("name: role\nclient_id: other\nowner_user_id: owner\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{RoleConfigPath: rolePath}); err == nil {
|
||||||
|
t.Fatal("mismatched role returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DWS_AGENT_CMD", "")
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{}); err == nil {
|
||||||
|
t.Fatal("missing custom agent command returned nil")
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok")
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{KnowledgeDir: filepath.Join(t.TempDir(), "missing")}); err == nil {
|
||||||
|
t.Fatal("missing knowledge directory returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
knowledge := t.TempDir()
|
||||||
|
writeKnowledgeFile(t, knowledge, "guide.md")
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{KnowledgeSource: filepath.Join(knowledge, "missing")}); err == nil {
|
||||||
|
t.Fatal("missing knowledge source returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchConnectorFullStreamAssemblyCoverage(t *testing.T) {
|
||||||
|
t.Setenv("DWS_CONNECT_CMD", "")
|
||||||
|
t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok")
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
knowledgeA := t.TempDir()
|
||||||
|
knowledgeB := t.TempDir()
|
||||||
|
writeKnowledgeFile(t, knowledgeA, "a.md")
|
||||||
|
writeKnowledgeFile(t, knowledgeB, "b.txt")
|
||||||
|
rolePath := filepath.Join(t.TempDir(), "role.yaml")
|
||||||
|
role := fmt.Sprintf("name: helper\nclient_id: id\nowner_user_id: owner\nconfirm_policy: auto\npersona: precise\nknowledge_sources:\n - %s\n - %s\nallowed_scopes:\n - todo\n", knowledgeA, knowledgeB)
|
||||||
|
if err := os.WriteFile(rolePath, []byte(role), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origRun := devAppRunStreamConnector
|
||||||
|
origLoad := devAppLoadConnectKnowledge
|
||||||
|
t.Cleanup(func() {
|
||||||
|
devAppRunStreamConnector = origRun
|
||||||
|
devAppLoadConnectKnowledge = origLoad
|
||||||
|
})
|
||||||
|
var calls int
|
||||||
|
devAppLoadConnectKnowledge = func(cmd *cobra.Command, runner executor.Runner, clientID, raw string) (*knowledgeBase, error) {
|
||||||
|
calls++
|
||||||
|
switch raw {
|
||||||
|
case "nil-source":
|
||||||
|
return nil, nil
|
||||||
|
case "error-source":
|
||||||
|
return nil, errors.New("source failed")
|
||||||
|
default:
|
||||||
|
return loadConnectKnowledgeSource(cmd, runner, clientID, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var gotExtras *connectExtras
|
||||||
|
var gotCard *aiCardClient
|
||||||
|
devAppRunStreamConnector = func(_ context.Context, channel, clientID, clientSecret string, fwd forwarder, card *aiCardClient, extras *connectExtras) error {
|
||||||
|
if channel != "custom" || clientID != "id" || clientSecret != "secret" || fwd == nil {
|
||||||
|
return fmt.Errorf("unexpected stream arguments")
|
||||||
|
}
|
||||||
|
gotExtras, gotCard = extras, card
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, out := connectCoverageCommand(t)
|
||||||
|
opts := connectAgentOptions{
|
||||||
|
RoleConfigPath: rolePath, ReplyCard: true, CardTemplate: "card-template",
|
||||||
|
KnowledgeDir: knowledgeA, KnowledgeSource: knowledgeB,
|
||||||
|
KnowledgeSources: []string{"nil-source", knowledgeA, knowledgeB},
|
||||||
|
AllowedUsers: []string{"u1"}, AllowedGroups: []string{"g1"}, UserRateLimit: 3,
|
||||||
|
OwnerUserID: "owner", ApprovalCardTemplate: "approval-template",
|
||||||
|
AuditSheetNode: "node", AuditSheetTab: "tab", RoleScopes: []string{"todo"}, ConfirmPolicy: "auto",
|
||||||
|
}
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", opts); err != nil {
|
||||||
|
t.Fatalf("launchConnector: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if calls < 5 || gotExtras == nil || gotExtras.kb == nil || gotExtras.gate == nil || gotExtras.approval == nil || gotCard == nil {
|
||||||
|
t.Fatalf("assembled connector incomplete: calls=%d extras=%+v card=%v", calls, gotExtras, gotCard)
|
||||||
|
}
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{
|
||||||
|
KnowledgeSources: []string{knowledgeA},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first role knowledge source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No card template selects the text-approval path and also covers the
|
||||||
|
// card-enabled/plain-template reply style. A cancelled context stops retry.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{
|
||||||
|
ReplyCard: true, OwnerUserID: "owner", WorkDir: t.TempDir(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{
|
||||||
|
KnowledgeSources: []string{"error-source"},
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("role knowledge error returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAgentOptionRemainingCoverage(t *testing.T) {
|
||||||
|
clearChannelEnv(t)
|
||||||
|
t.Setenv("DWS_REPLY_CARD", "false")
|
||||||
|
t.Setenv("DWS_AGENT_MODEL", "model")
|
||||||
|
t.Setenv("DWS_AGENT_WORKDIR", "/work")
|
||||||
|
t.Setenv("DWS_CARD_TEMPLATE", "public")
|
||||||
|
t.Setenv("DWS_KNOWLEDGE_DIR", "/knowledge")
|
||||||
|
t.Setenv("DWS_KNOWLEDGE_SOURCE", "wiki:space")
|
||||||
|
t.Setenv("DWS_ALLOWED_USERS", "u1,u2")
|
||||||
|
t.Setenv("DWS_ALLOWED_GROUPS", "g1")
|
||||||
|
t.Setenv("DWS_USER_RATE_LIMIT", "7")
|
||||||
|
t.Setenv("DWS_OWNER_USER_ID", "owner")
|
||||||
|
t.Setenv("DWS_APPROVAL_CARD_TEMPLATE", "approval")
|
||||||
|
t.Setenv("DWS_ROLE_CONFIG", "role.yaml")
|
||||||
|
t.Setenv("DWS_AUDIT_SHEET", "node")
|
||||||
|
t.Setenv("DWS_AUDIT_SHEET_TAB", "tab")
|
||||||
|
cmd := newDevAppRobotConnectCommand(nil)
|
||||||
|
if err := cmd.Flags().Set("audit-sheet-tab", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts, err := connectAgentOptionsFromCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if opts.Model != "model" || opts.WorkDir != "/work" || opts.ReplyCard || opts.CardTemplate != defaultAICardTemplateID || opts.UserRateLimit != 7 || opts.AuditSheetTab != "tab" {
|
||||||
|
t.Fatalf("env options = %+v", opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DWS_AUDIT_SHEET_TAB", "")
|
||||||
|
opts, err = connectAgentOptionsFromCommand(cmd)
|
||||||
|
if err != nil || opts.AuditSheetTab != "Sheet1" {
|
||||||
|
t.Fatalf("default audit tab = %q, err=%v", opts.AuditSheetTab, err)
|
||||||
|
}
|
||||||
|
t.Setenv("DWS_USER_RATE_LIMIT", "invalid")
|
||||||
|
if _, err := connectAgentOptionsFromCommand(cmd); err != nil {
|
||||||
|
t.Fatalf("invalid rate should retain default: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
flag, value, env, envValue string
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{env: "DWS_AGENT_PERMISSION_MODE", envValue: "ask", want: false},
|
||||||
|
{env: "DWS_AGENT_PERMISSION_MODE", envValue: "bad", wantErr: true},
|
||||||
|
{flag: "agent-approval-mode", value: "ask", want: false},
|
||||||
|
{env: "DWS_AGENT_APPROVAL_MODE", envValue: "ask", want: false},
|
||||||
|
{env: "DWS_AGENT_APPROVAL_MODE", envValue: "bad", wantErr: true},
|
||||||
|
}
|
||||||
|
for i, tc := range cases {
|
||||||
|
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||||
|
clearChannelEnv(t)
|
||||||
|
c := newDevAppRobotConnectCommand(nil)
|
||||||
|
if tc.env != "" {
|
||||||
|
t.Setenv(tc.env, tc.envValue)
|
||||||
|
}
|
||||||
|
if tc.flag != "" {
|
||||||
|
if err := c.Flags().Set(tc.flag, tc.value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := resolveAgentYoloMode(c)
|
||||||
|
if (err != nil) != tc.wantErr || (!tc.wantErr && got != tc.want) {
|
||||||
|
t.Fatalf("got (%v,%v), want (%v, err=%v)", got, err, tc.want, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAgentOptionsPayloadRemainingCoverage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
channel string
|
||||||
|
memory bool
|
||||||
|
}{
|
||||||
|
{"codex", false}, {"opencode", true}, {"opencode", false},
|
||||||
|
{"claudecode", true}, {"claudecode", false}, {"gemini", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
payload := connectAgentOptionsPayload(tc.channel, connectAgentOptions{
|
||||||
|
Memory: tc.memory, ReplyCard: true, CardTemplate: "template", Yolo: true,
|
||||||
|
})
|
||||||
|
if payload["replyStyle"] != "ai-card" || payload["yolo"] != "enabled" {
|
||||||
|
t.Fatalf("payload(%s,%v) = %#v", tc.channel, tc.memory, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppConnectCommandRemainingCoverage(t *testing.T) {
|
||||||
|
origInteractive := devAppConnectStdinInteractive
|
||||||
|
origOnboarding := devAppRunConnectOnboarding
|
||||||
|
origDaemon := devAppStartConnectDaemon
|
||||||
|
origStream := devAppRunStreamConnector
|
||||||
|
t.Cleanup(func() {
|
||||||
|
devAppConnectStdinInteractive = origInteractive
|
||||||
|
devAppRunConnectOnboarding = origOnboarding
|
||||||
|
devAppStartConnectDaemon = origDaemon
|
||||||
|
devAppRunStreamConnector = origStream
|
||||||
|
})
|
||||||
|
devAppRunStreamConnector = func(context.Context, string, string, string, forwarder, *aiCardClient, *connectExtras) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(runner executor.Runner, args ...string) (string, error) {
|
||||||
|
root := newDevAppTestRoot(runner)
|
||||||
|
var out bytes.Buffer
|
||||||
|
root.SetOut(&out)
|
||||||
|
root.SetErr(&out)
|
||||||
|
root.SetIn(strings.NewReader(""))
|
||||||
|
root.SetArgs(append([]string{"dev", "connect"}, args...))
|
||||||
|
return out.String(), root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChannelEnv(t)
|
||||||
|
if _, err := run(&captureRunner{}, "--dry-run"); err == nil {
|
||||||
|
t.Fatal("undetected channel returned nil")
|
||||||
|
}
|
||||||
|
if _, err := run(&captureRunner{}, "--channel", "custom", "--agent-cmd", "sh -c printf\\ ok", "--robot-client-id", "id", "--robot-client-secret", "secret"); err != nil {
|
||||||
|
t.Fatalf("agent-cmd launch: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := run(&captureRunner{}, "--channel", "custom", "--robot-client-id", "id", "--robot-client-secret", "secret", "--agent-permission-mode", "bad"); err == nil {
|
||||||
|
t.Fatal("invalid agent options returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
boom := errors.New("credentials boom")
|
||||||
|
if _, err := run(connectCoverageRunner{err: boom}, "--channel", "hermes", "--unified-app-id", "app"); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("credential error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := run(connectCoverageRunner{response: map[string]any{}}, "--channel", "hermes", "--unified-app-id", "app"); err == nil {
|
||||||
|
t.Fatal("empty credentials returned nil")
|
||||||
|
}
|
||||||
|
response := map[string]any{"result": map[string]any{"clientId": "id", "clientSecret": "secret"}}
|
||||||
|
if _, err := run(connectCoverageRunner{response: response}, "--channel", "hermes", "--unified-app-id", "app"); err != nil {
|
||||||
|
t.Fatalf("resolved credentials launch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devAppConnectStdinInteractive = func() bool { return true }
|
||||||
|
devAppRunConnectOnboarding = func(executor.Runner, *cobra.Command, io.Reader, io.Writer) (connectCreds, error) {
|
||||||
|
return connectCreds{}, boom
|
||||||
|
}
|
||||||
|
if _, err := run(&captureRunner{}, "--channel", "hermes"); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("onboarding error = %v", err)
|
||||||
|
}
|
||||||
|
devAppRunConnectOnboarding = func(executor.Runner, *cobra.Command, io.Reader, io.Writer) (connectCreds, error) {
|
||||||
|
return connectCreds{clientID: "id", clientSecret: "secret", source: "onboarding"}, nil
|
||||||
|
}
|
||||||
|
if _, err := run(&captureRunner{}, "--channel", "hermes"); err != nil {
|
||||||
|
t.Fatalf("onboarding success: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var daemonCalled bool
|
||||||
|
devAppStartConnectDaemon = func(_ *cobra.Command, dirKey, clientID, unifiedAppID, channel, notifyStaffID, profile string, alwaysOn bool) error {
|
||||||
|
daemonCalled = dirKey != "" && clientID == "id" && channel == "hermes" && notifyStaffID == "staff" && alwaysOn
|
||||||
|
return boom
|
||||||
|
}
|
||||||
|
if _, err := run(&captureRunner{}, "--channel", "hermes", "--robot-client-id", "id", "--robot-client-secret", "secret", "--daemon", "--alwayson", "--notify-staff-id", "staff"); !errors.Is(err, boom) || !daemonCalled {
|
||||||
|
t.Fatalf("daemon result err=%v called=%v", err, daemonCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChannelEnv(t)
|
||||||
|
if _, err := run(&captureRunner{}, "--daemon-supervise"); err == nil || !strings.Contains(err.Error(), "DIRKEY") {
|
||||||
|
t.Fatalf("supervisor error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevAppFetchCredentialsErrorCoverage(t *testing.T) {
|
||||||
|
cmd, _ := connectCoverageCommand(t)
|
||||||
|
want := errors.New("runner failure")
|
||||||
|
if _, _, err := devAppFetchCredentials(connectCoverageRunner{err: want}, cmd, "app"); !errors.Is(err, want) {
|
||||||
|
t.Fatalf("devAppFetchCredentials error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDevAppAsIntRemainingTypes(t *testing.T) {
|
||||||
|
for _, value := range []any{int(1), int64(1), float64(1), json.Number("1"), "1"} {
|
||||||
|
if got, ok := devAppAsInt(value); !ok || got != 1 {
|
||||||
|
t.Fatalf("devAppAsInt(%T(%v))=%d,%v", value, value, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []any{json.Number("bad"), "bad", true} {
|
||||||
|
if got, ok := devAppAsInt(value); ok || got != 0 {
|
||||||
|
t.Fatalf("invalid devAppAsInt(%T(%v))=%d,%v", value, value, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
repairJSONML = jsonrepair.RepairJSON
|
||||||
|
diagnoseJSONMLError = doc.DiagnoseJSONError
|
||||||
|
)
|
||||||
|
|
||||||
// isInputDangerousUnicode matches the dangerous-unicode set defined by the
|
// isInputDangerousUnicode matches the dangerous-unicode set defined by the
|
||||||
// server-side RejectControlChars validator (pkg/validate/input.go).
|
// server-side RejectControlChars validator (pkg/validate/input.go).
|
||||||
func isInputDangerousUnicode(r rune) bool {
|
func isInputDangerousUnicode(r rune) bool {
|
||||||
@@ -93,10 +98,7 @@ func coerceJsonMLBodyShape(raw string) (string, []string, error) {
|
|||||||
return raw, nil, nil
|
return raw, nil, nil
|
||||||
}
|
}
|
||||||
if _, ok := probe.([]any); ok {
|
if _, ok := probe.([]any); ok {
|
||||||
wrapped, err := json.Marshal(map[string]any{"jsonml": probe})
|
wrapped, _ := json.Marshal(map[string]any{"jsonml": probe})
|
||||||
if err != nil {
|
|
||||||
return raw, nil, fmt.Errorf("wrap bare jsonml array: %w", err)
|
|
||||||
}
|
|
||||||
return string(wrapped), nil, nil
|
return string(wrapped), nil, nil
|
||||||
}
|
}
|
||||||
return raw, nil, nil
|
return raw, nil, nil
|
||||||
@@ -137,10 +139,7 @@ func coerceJsonMLNodeShape(raw string) (string, []string, error) {
|
|||||||
case 0:
|
case 0:
|
||||||
return "", nil, fmt.Errorf(`--content-format jsonml 输入 {"jsonml":[]}: wrapper 中 jsonml 数组为空`)
|
return "", nil, fmt.Errorf(`--content-format jsonml 输入 {"jsonml":[]}: wrapper 中 jsonml 数组为空`)
|
||||||
case 1:
|
case 1:
|
||||||
out, err := json.Marshal(arr[0])
|
out, _ := json.Marshal(arr[0])
|
||||||
if err != nil {
|
|
||||||
return raw, nil, fmt.Errorf("unwrap single jsonml node: %w", err)
|
|
||||||
}
|
|
||||||
note := `输入为 {"jsonml":[node]} body 形态,已自动解包为单节点以符合 block 命令协议`
|
note := `输入为 {"jsonml":[node]} body 形态,已自动解包为单节点以符合 block 命令协议`
|
||||||
return string(out), []string{note}, nil
|
return string(out), []string{note}, nil
|
||||||
default:
|
default:
|
||||||
@@ -170,7 +169,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
|
|||||||
if fixJSON {
|
if fixJSON {
|
||||||
var probe any
|
var probe any
|
||||||
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
if err := json.Unmarshal([]byte(raw), &probe); err != nil {
|
||||||
result, repairErr := jsonrepair.RepairJSON(raw)
|
result, repairErr := repairJSONML(raw)
|
||||||
if repairErr != nil {
|
if repairErr != nil {
|
||||||
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
|
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
|
||||||
}
|
}
|
||||||
@@ -181,10 +180,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Coerce shape — bare array → {"jsonml": [...]} wrapper.
|
// Step 2: Coerce shape — bare array → {"jsonml": [...]} wrapper.
|
||||||
coerced, coerceNotes, err := coerceJsonMLBodyShape(raw)
|
coerced, coerceNotes, _ := coerceJsonMLBodyShape(raw)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
emitFixNotes(coerceNotes)
|
emitFixNotes(coerceNotes)
|
||||||
raw = coerced
|
raw = coerced
|
||||||
|
|
||||||
@@ -193,7 +189,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(raw), &wrapper); err != nil {
|
if err := json.Unmarshal([]byte(raw), &wrapper); err != nil {
|
||||||
if !fixJSON {
|
if !fixJSON {
|
||||||
// Use parser for detailed positional diagnostics
|
// Use parser for detailed positional diagnostics
|
||||||
detail := doc.DiagnoseJSONError([]byte(raw))
|
detail := diagnoseJSONMLError([]byte(raw))
|
||||||
if detail != "" {
|
if detail != "" {
|
||||||
return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail)
|
return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail)
|
||||||
}
|
}
|
||||||
@@ -238,10 +234,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
|
|||||||
fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary)
|
fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := json.Marshal(bodyArr)
|
out, _ := json.Marshal(bodyArr)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal jsonml: %w", err)
|
|
||||||
}
|
|
||||||
cleaned := stripInputUnsafeChars(string(out))
|
cleaned := stripInputUnsafeChars(string(out))
|
||||||
return cleaned, nil
|
return cleaned, nil
|
||||||
}
|
}
|
||||||
@@ -265,7 +258,7 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) {
|
|||||||
if fixJSON {
|
if fixJSON {
|
||||||
var probe any
|
var probe any
|
||||||
if err := json.Unmarshal([]byte(rawElement), &probe); err != nil {
|
if err := json.Unmarshal([]byte(rawElement), &probe); err != nil {
|
||||||
result, repairErr := jsonrepair.RepairJSON(rawElement)
|
result, repairErr := repairJSONML(rawElement)
|
||||||
if repairErr != nil {
|
if repairErr != nil {
|
||||||
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
|
return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err)
|
||||||
}
|
}
|
||||||
@@ -288,14 +281,13 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(rawElement), &node); err != nil {
|
if err := json.Unmarshal([]byte(rawElement), &node); err != nil {
|
||||||
if !fixJSON {
|
if !fixJSON {
|
||||||
// Use parser for detailed positional diagnostics
|
// Use parser for detailed positional diagnostics
|
||||||
detail := doc.DiagnoseJSONError([]byte(rawElement))
|
detail := diagnoseJSONMLError([]byte(rawElement))
|
||||||
if detail != "" {
|
if detail != "" {
|
||||||
return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail)
|
return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err)
|
return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err)
|
||||||
}
|
}
|
||||||
// fixJSON was true but repair+coerce still couldn't produce valid JSON
|
return "", fmt.Errorf("JSON 语法错误: %w", err)
|
||||||
return "", fmt.Errorf("JSON 修复后仍无法解析: %w", err)
|
|
||||||
}
|
}
|
||||||
if _, ok := node.([]any); !ok {
|
if _, ok := node.([]any); !ok {
|
||||||
return "", fmt.Errorf("--content-format jsonml 要求 --element 为 JSON 数组,实际类型: %T", node)
|
return "", fmt.Errorf("--content-format jsonml 要求 --element 为 JSON 数组,实际类型: %T", node)
|
||||||
@@ -318,10 +310,7 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) {
|
|||||||
fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary)
|
fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := json.Marshal(node)
|
out, _ := json.Marshal(node)
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("marshal jsonml: %w", err)
|
|
||||||
}
|
|
||||||
return stripInputUnsafeChars(string(out)), nil
|
return stripInputUnsafeChars(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareJSONMLRemainingErrorPaths(t *testing.T) {
|
||||||
|
strict := jsonMLTestCommand(t, false)
|
||||||
|
fixing := jsonMLTestCommand(t, true)
|
||||||
|
|
||||||
|
if _, err := prepareJsonMLNode(strict, ""); err == nil {
|
||||||
|
t.Fatal("empty node returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
origRepair := repairJSONML
|
||||||
|
repairJSONML = func(string) (string, error) { return "", errors.New("repair failed") }
|
||||||
|
if _, err := prepareJsonMLBody(fixing, "{"); err == nil || !strings.Contains(err.Error(), "repair failed") {
|
||||||
|
t.Fatalf("body repair err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := prepareJsonMLNode(fixing, "{"); err == nil || !strings.Contains(err.Error(), "repair failed") {
|
||||||
|
t.Fatalf("node repair err=%v", err)
|
||||||
|
}
|
||||||
|
repairJSONML = origRepair
|
||||||
|
t.Cleanup(func() { repairJSONML = origRepair })
|
||||||
|
repairJSONML = func(string) (string, error) { return "{", nil }
|
||||||
|
if _, err := prepareJsonMLNode(fixing, "broken"); err == nil || !strings.Contains(err.Error(), "JSON 语法错误") {
|
||||||
|
t.Fatalf("invalid repaired node err=%v", err)
|
||||||
|
}
|
||||||
|
repairJSONML = origRepair
|
||||||
|
|
||||||
|
// Exercise the concise syntax-error diagnostic when the detailed parser has
|
||||||
|
// no additional location information.
|
||||||
|
origDiagnose := diagnoseJSONMLError
|
||||||
|
diagnoseJSONMLError = func([]byte) string { return "" }
|
||||||
|
if _, err := prepareJsonMLBody(strict, "{"); err == nil || !strings.Contains(err.Error(), "输入不是有效") {
|
||||||
|
t.Fatalf("body concise diagnostic err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := prepareJsonMLNode(strict, "{"); err == nil || !strings.Contains(err.Error(), "输入不是有效") {
|
||||||
|
t.Fatalf("node concise diagnostic err=%v", err)
|
||||||
|
}
|
||||||
|
diagnoseJSONMLError = func([]byte) string { return "line 1" }
|
||||||
|
if _, err := prepareJsonMLBody(strict, "{"); err == nil || !strings.Contains(err.Error(), "line 1") {
|
||||||
|
t.Fatalf("body detailed diagnostic err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := prepareJsonMLNode(strict, "{"); err == nil || !strings.Contains(err.Error(), "line 1") {
|
||||||
|
t.Fatalf("node detailed diagnostic err=%v", err)
|
||||||
|
}
|
||||||
|
diagnoseJSONMLError = origDiagnose
|
||||||
|
|
||||||
|
// Valid JSON with the wrong top-level shape is not a syntax-repair case.
|
||||||
|
if _, err := prepareJsonMLBody(fixing, `"scalar"`); err == nil || !strings.Contains(err.Error(), "修复后仍无法解析") {
|
||||||
|
t.Fatalf("fixed scalar body err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := prepareJsonMLBody(strict, `{"jsonml":["root",{},["p",{"jc":1}]]}`); err == nil || !strings.Contains(err.Error(), "格式校验失败") {
|
||||||
|
t.Fatalf("body validation err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := prepareJsonMLNode(strict, `["p",{"jc":1}]`); err == nil || !strings.Contains(err.Error(), "格式校验失败") {
|
||||||
|
t.Fatalf("node validation err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func docWriteCoverageCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{Use: "write"}
|
||||||
|
cmd.Flags().String("content", "", "")
|
||||||
|
cmd.Flags().String("content-file", "", "")
|
||||||
|
cmd.Flags().String("content-path", "", "")
|
||||||
|
cmd.Flags().String("markdown", "", "")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocWritePipelineStrategyRemainingCoverage(t *testing.T) {
|
||||||
|
oldArgs := os.Args
|
||||||
|
os.Args = []string{"dws", "doc"}
|
||||||
|
t.Cleanup(func() { os.Args = oldArgs })
|
||||||
|
stdinCmd := docWriteCoverageCommand()
|
||||||
|
_ = stdinCmd.Flags().Set("markdown", "-")
|
||||||
|
if got := detectContentSource(stdinCmd); got != sourceStdin {
|
||||||
|
t.Fatalf("markdown stdin source = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
longLiteral := strings.Repeat("x", longContentWarningThreshold+1)
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"nodeId":"node"}`}}})
|
||||||
|
if err := docWritePipeline(docWriteCoverageCommand(), "create_document", map[string]any{"markdown": longLiteral}, longLiteral, "create"); err != nil {
|
||||||
|
t.Fatalf("long literal single write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := strings.Repeat("x", minChunkSize+100)
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
|
||||||
|
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
|
||||||
|
t.Fatalf("single timeout fallback: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunked := strings.Repeat("x", initialChunkSize+100)
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}}})
|
||||||
|
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "markdown": chunked}, chunked, "update"); err != nil {
|
||||||
|
t.Fatalf("long content chunking: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkedWriteAdaptiveRetryRemainingCoverage(t *testing.T) {
|
||||||
|
oldArgs := os.Args
|
||||||
|
os.Args = []string{"dws", "doc"}
|
||||||
|
t.Cleanup(func() { os.Args = oldArgs })
|
||||||
|
markdown := strings.Repeat("x", 24000)
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
|
||||||
|
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
|
||||||
|
if err != nil || written < 3 {
|
||||||
|
t.Fatalf("adaptive retry written=%d err=%v", written, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func executeDriveEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
oldDeps := deps
|
||||||
|
oldArgs := os.Args
|
||||||
|
InitDeps(caller)
|
||||||
|
deps.Out.w = io.Discard
|
||||||
|
deps.Out.errW = io.Discard
|
||||||
|
t.Cleanup(func() {
|
||||||
|
deps = oldDeps
|
||||||
|
os.Args = oldArgs
|
||||||
|
})
|
||||||
|
root := newDriveCommand()
|
||||||
|
installExampleGlobalFlags(root)
|
||||||
|
root.SilenceErrors = true
|
||||||
|
root.SilenceUsage = true
|
||||||
|
root.SetOut(io.Discard)
|
||||||
|
root.SetErr(io.Discard)
|
||||||
|
root.SetArgs(args)
|
||||||
|
os.Args = append([]string{"dws", "drive"}, args...)
|
||||||
|
return root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDriveUploadInfoRemainingCoverage(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"resource array", `{"result":{"uploadId":"u","resourceUrls":[{"url":"https://upload.invalid","headers":{"X-Test":"yes","skip":1}}]}}`, false},
|
||||||
|
{"flat resource and headers", `{"uploadId":"u","resourceUrl":"https://upload.invalid","headers":{"X-Test":"yes","skip":1}}`, false},
|
||||||
|
{"upload URL fallback", `{"uploadId":"u","uploadUrl":"https://upload.invalid"}`, false},
|
||||||
|
{"non-map first URL", `{"uploadId":"u","resourceUrls":[1]}`, true},
|
||||||
|
{"missing upload id", `{"resourceUrl":"https://upload.invalid"}`, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
url, id, headers, err := parseDriveUploadInfo(tc.json)
|
||||||
|
if (err != nil) != tc.err {
|
||||||
|
t.Fatalf("parse error=%v, wantErr=%v", err, tc.err)
|
||||||
|
}
|
||||||
|
if !tc.err && (url == "" || id == "" || headers == nil) {
|
||||||
|
t.Fatalf("parse result url=%q id=%q headers=%v", url, id, headers)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveUploadValidationAndDryRunCoverage(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "fixture.txt")
|
||||||
|
if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"missing file", []string{"upload"}, true},
|
||||||
|
{"unreadable file", []string{"upload", "--file", file + ".missing"}, true},
|
||||||
|
{"directory", []string{"upload", "--file", t.TempDir()}, true},
|
||||||
|
{"numeric drive folder", []string{"upload", "--file", file, "--folder", "123"}, true},
|
||||||
|
{"drive dry run", []string{"upload", "--file", file, "--file-name", "named.txt", "--dry-run"}, false},
|
||||||
|
{"numeric doc folder", []string{"upload", "--file", file, "--workspace", "space", "--folder", "123"}, true},
|
||||||
|
{"doc dry run extension", []string{"upload", "--file", file, "--file-name", "named", "--workspace", "space", "--dry-run"}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := executeDriveEdge(t, &scriptedToolCaller{dry: strings.Contains(strings.Join(tc.args, " "), "--dry-run")}, tc.args...)
|
||||||
|
if (err != nil) != tc.err {
|
||||||
|
t.Fatalf("Execute(%v) error=%v, wantErr=%v", tc.args, err, tc.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveUploadTransportCoverage(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "fixture.txt")
|
||||||
|
if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
boom := errors.New("boom")
|
||||||
|
t.Run("drive credentials error", func(t *testing.T) {
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, "upload", "--file", file); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("drive credentials parse error", func(t *testing.T) {
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, "upload", "--file", file); err == nil {
|
||||||
|
t.Fatal("parse error returned nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("drive put error", func(t *testing.T) {
|
||||||
|
old := httpPutFile
|
||||||
|
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return boom }
|
||||||
|
t.Cleanup(func() { httpPutFile = old })
|
||||||
|
payload := `{"uploadId":"u","resourceUrl":"https://upload.invalid"}`
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}, "upload", "--file", file); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("drive commit", func(t *testing.T) {
|
||||||
|
old := httpPutFile
|
||||||
|
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||||
|
t.Cleanup(func() { httpPutFile = old })
|
||||||
|
payload := `{"uploadId":"u","resourceUrl":"https://upload.invalid"}`
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}, {text: `{}`}}}, "upload", "--file", file, "--space-id", "space", "--folder", "uuid"); err != nil {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
docArgs := []string{"upload", "--file", file, "--workspace", "space", "--folder", "uuid", "--convert"}
|
||||||
|
t.Run("doc credentials error", func(t *testing.T) {
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, docArgs...); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("doc credentials parse error", func(t *testing.T) {
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, docArgs...); err == nil {
|
||||||
|
t.Fatal("parse error returned nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("doc put error", func(t *testing.T) {
|
||||||
|
old := httpPutFile
|
||||||
|
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return boom }
|
||||||
|
t.Cleanup(func() { httpPutFile = old })
|
||||||
|
payload := `{"resourceUrl":"https://upload.invalid","uploadKey":"key"}`
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}, docArgs...); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("doc commit", func(t *testing.T) {
|
||||||
|
old := httpPutFile
|
||||||
|
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||||
|
t.Cleanup(func() { httpPutFile = old })
|
||||||
|
payload := `{"resourceUrl":"https://upload.invalid","uploadKey":"key"}`
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}, {text: `{}`}}}, docArgs...); err != nil {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveCommandRemainingEdges(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "fixture.txt")
|
||||||
|
_ = os.WriteFile(file, []byte("fixture"), 0o600)
|
||||||
|
cases := [][]string{
|
||||||
|
{"list", "--workspace", "space", "--folder", "123"},
|
||||||
|
{"mkdir", "--name", "folder", "--folder", "123"},
|
||||||
|
{"upload-info", "--file-name", "x", "--file-size", "1", "--folder", "123"},
|
||||||
|
{"commit", "--file-name", "x", "--file-size", "1", "--upload-id", "u", "--folder", "123"},
|
||||||
|
{"copy", "--node", "node", "--folder", "123"},
|
||||||
|
{"move", "--node", "node", "--folder", "123"},
|
||||||
|
}
|
||||||
|
for _, args := range cases {
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{}, args...); err == nil {
|
||||||
|
t.Fatalf("Execute(%v) returned nil", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{dry: true}, "download", "--node", "node", "--output", file, "--dry-run"); err != nil {
|
||||||
|
t.Fatalf("dry download: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveDownloadDirectoryCoverage(t *testing.T) {
|
||||||
|
oldGet := httpGetFile
|
||||||
|
httpGetFile = func(context.Context, string, map[string]string, string) error { return nil }
|
||||||
|
t.Cleanup(func() { httpGetFile = oldGet })
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, payload := range []string{
|
||||||
|
`{"resourceUrl":"https://download.invalid/path/from-url.txt","fileName":""}`,
|
||||||
|
`{"resourceUrl":"https://download.invalid/path/from-url.txt","fileName":"folder/name.txt"}`,
|
||||||
|
} {
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}, "download", "--node", "node", "--output", dir); err != nil {
|
||||||
|
t.Fatalf("download: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveConfirmationCancellationCoverage(t *testing.T) {
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"delete", "--node", "node"},
|
||||||
|
{"publish", "set", "--node", "node"},
|
||||||
|
{"publish", "unset", "--node", "node"},
|
||||||
|
} {
|
||||||
|
file, err := os.CreateTemp(t.TempDir(), "stdin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := file.WriteString("no\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = file.Seek(0, 0)
|
||||||
|
os.Stdin = file
|
||||||
|
if err := executeDriveEdge(t, &scriptedToolCaller{}, args...); err != nil {
|
||||||
|
t.Fatalf("cancel %v: %v", args, err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveInfoDocFallbackCoverage(t *testing.T) {
|
||||||
|
oldArgs := os.Args
|
||||||
|
os.Args = []string{"dws", "drive", "info"}
|
||||||
|
t.Cleanup(func() { os.Args = oldArgs })
|
||||||
|
boom := errors.New("boom")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
steps []scriptedToolStep
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"drive error", []scriptedToolStep{{err: boom}}, true},
|
||||||
|
{"invalid drive JSON", []scriptedToolStep{{text: `{`}}, false},
|
||||||
|
{"no result", []scriptedToolStep{{text: `{}`}}, false},
|
||||||
|
{"ordinary file", []scriptedToolStep{{text: `{"result":{"extension":"pdf"}}`}}, false},
|
||||||
|
{"doc lookup error", []scriptedToolStep{{text: `{"result":{"message":"钉钉文档","fileId":""}}`}, {err: boom}}, false},
|
||||||
|
{"invalid doc JSON", []scriptedToolStep{{text: `{"result":{"extension":"adoc","fileId":"node"}}`}, {text: `{`}}, false},
|
||||||
|
{"empty flat doc", []scriptedToolStep{{text: `{"result":{"extension":"axls","fileId":"node"}}`}, {text: `{}`}}, false},
|
||||||
|
{"flat doc merge", []scriptedToolStep{{text: `{"result":{"extension":"amind","fileId":"node","path":"/drive","fileSize":3,"type":"doc"}}`}, {text: `{"title":"Doc","path":"existing"}`}}, false},
|
||||||
|
{"wrapped doc", []scriptedToolStep{{text: `{"result":{"extension":"adraw","fileId":"node","dentryId":"d"}}`}, {text: `{"result":{"title":"Doc"}}`}}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
caller := &scriptedToolCaller{steps: tc.steps}
|
||||||
|
old := deps
|
||||||
|
InitDeps(caller)
|
||||||
|
deps.Out.w = &bytes.Buffer{}
|
||||||
|
deps.Out.errW = io.Discard
|
||||||
|
t.Cleanup(func() { deps = old })
|
||||||
|
err := driveInfoWithDocFallback("fallback-node", map[string]any{"fileId": "fallback-node"})
|
||||||
|
if (err != nil) != tc.err {
|
||||||
|
t.Fatalf("error=%v, wantErr=%v", err, tc.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveSmallHelperEdges(t *testing.T) {
|
||||||
|
for _, ext := range []string{"adoc", "AXLS", "amind", "adraw", "pdf"} {
|
||||||
|
_ = isDingTalkDocExtension(ext)
|
||||||
|
}
|
||||||
|
cmd := &cobra.Command{Use: "upload"}
|
||||||
|
cmd.Flags().String("file", "", "")
|
||||||
|
cmd.Flags().String("file-name", "", "")
|
||||||
|
cmd.Flags().String("name", "", "")
|
||||||
|
cmd.Flags().String("workspace", "", "")
|
||||||
|
cmd.Flags().String("workspace-id", "", "")
|
||||||
|
cmd.Flags().String("folder", "", "")
|
||||||
|
cmd.Flags().String("parent-id", "", "")
|
||||||
|
cmd.Flags().String("space-id", "", "")
|
||||||
|
cmd.Flags().String("mime-type", "", "")
|
||||||
|
cmd.Flags().Bool("convert", false, "")
|
||||||
|
if err := cmd.Flags().Set("file", t.TempDir()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := runDriveUpload(cmd, nil); err == nil {
|
||||||
|
t.Fatal("directory upload returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -409,11 +409,6 @@ func getCurrentUserID(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r, ok := flat["result"].(map[string]any); ok {
|
|
||||||
if uid, ok := r["userId"].(string); ok && uid != "" {
|
|
||||||
return uid, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("cannot parse userId from get_current_user_profile response")
|
return "", fmt.Errorf("cannot parse userId from get_current_user_profile response")
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMCPInternalClassificationAndUnescapeRemainingCoverage(t *testing.T) {
|
||||||
|
caller := &helpersCoreCaller{format: "raw"}
|
||||||
|
out, _ := installHelpersCoreDeps(t, caller)
|
||||||
|
|
||||||
|
caller.err = errors.New("request failed with PAT_HIGH_RISK_NO_PERMISSION")
|
||||||
|
if err := callMCPToolInternalOpts("server", "tool", nil, false); err == nil {
|
||||||
|
t.Fatal("PAT transport error returned nil")
|
||||||
|
}
|
||||||
|
caller.err = nil
|
||||||
|
for _, response := range []string{
|
||||||
|
`{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`,
|
||||||
|
`{"error":"Missing service_id or access_key"}`,
|
||||||
|
`{"code":"PAT_NO_PERMISSION"}`,
|
||||||
|
} {
|
||||||
|
caller.result = textToolResult(response)
|
||||||
|
if err := callMCPToolInternalOpts("server", "tool", nil, false); err == nil {
|
||||||
|
t.Fatalf("classified response %s returned nil", response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Reset()
|
||||||
|
caller.result = textToolResult(`{"url":"https://example.test/?a=1\u0026b=2"}`)
|
||||||
|
if err := callMCPToolInternalOpts("server", "tool", nil, true); err != nil || !strings.Contains(out.String(), "&") {
|
||||||
|
t.Fatalf("unescaped raw output=%q err=%v", out.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentUserMalformedTailFallbackCoverage(t *testing.T) {
|
||||||
|
caller := &helpersCoreCaller{result: textToolResult(`{"result":[{"orgEmployeeModel":{"userId":"fallback-user"}},{"orgEmployeeModel":"malformed"}]}`)}
|
||||||
|
installHelpersCoreDeps(t, caller)
|
||||||
|
if got, err := getCurrentUserID(context.Background()); err != nil || got != "fallback-user" {
|
||||||
|
t.Fatalf("fallback user=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLastPureAndCommandBranches(t *testing.T) {
|
||||||
|
if commandBoolFlag(nil, "dry-run") {
|
||||||
|
t.Fatal("nil command flag is true")
|
||||||
|
}
|
||||||
|
SetCmdClassOverride(" ", CmdClassWrite)
|
||||||
|
formatCmd := &cobra.Command{Use: "format"}
|
||||||
|
formatCmd.Flags().String("content-format", "json", "")
|
||||||
|
if err := validateDocFormat(formatCmd, []string{"", "markdown"}, "doc create", "dws doc create"); err == nil {
|
||||||
|
t.Fatal("json content format returned nil")
|
||||||
|
}
|
||||||
|
if sniffJsonMLLike("") {
|
||||||
|
t.Fatal("empty content looks like JSONML")
|
||||||
|
}
|
||||||
|
if err := validateSegment("name", "ab-", 2, 50); err == nil {
|
||||||
|
t.Fatal("trailing hyphen name returned nil")
|
||||||
|
}
|
||||||
|
if got := NormalizeSkillName("Skill --- "); got != "skill" {
|
||||||
|
t.Fatalf("normalized skill=%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
aisearch := newAisearchCommand()
|
||||||
|
if err := aisearch.RunE(aisearch, nil); err != nil {
|
||||||
|
t.Fatalf("aisearch group help: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gate := newConnectGate(nil, nil, 1)
|
||||||
|
for i := 0; i < 4097; i++ {
|
||||||
|
gate.hits[string(rune(0x1000+i))] = nil
|
||||||
|
}
|
||||||
|
if ok, reason := gate.allow("sender", "1", "conv"); !ok || reason != "" || len(gate.hits) != 1 {
|
||||||
|
t.Fatalf("bounded gate ok=%v reason=%q size=%d", ok, reason, len(gate.hits))
|
||||||
|
}
|
||||||
|
|
||||||
|
q := newConvQueue()
|
||||||
|
if process := q.processFor("missing"); process != nil {
|
||||||
|
t.Fatal("missing queue process is non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := &RoleConfig{ConfirmPolicy: ConfirmManual}
|
||||||
|
merged := applyRoleConfig(connectAgentOptions{}, role)
|
||||||
|
if merged.ConfirmPolicy != string(ConfirmManual) {
|
||||||
|
t.Fatalf("confirm policy=%q", merged.ConfirmPolicy)
|
||||||
|
}
|
||||||
|
roleDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(roleDir, "bad.yaml"), []byte(":\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadRoleConfigs(roleDir); err == nil {
|
||||||
|
t.Fatal("invalid role directory returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
dev := (devHandler{}).Command(executor.EchoRunner{})
|
||||||
|
if err := dev.RunE(dev, nil); err != nil {
|
||||||
|
t.Fatalf("dev help: %v", err)
|
||||||
|
}
|
||||||
|
doc := findCoverageSubcommand(t, dev, "doc")
|
||||||
|
if err := doc.RunE(doc, nil); err != nil {
|
||||||
|
t.Fatalf("dev doc help: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastIOAndConfirmationBranches(t *testing.T) {
|
||||||
|
origGetrlimit := connectGetrlimit
|
||||||
|
connectGetrlimit = func(int, *syscall.Rlimit) error { return errors.New("rlimit") }
|
||||||
|
checkFDLimit()
|
||||||
|
connectGetrlimit = func(_ int, limit *syscall.Rlimit) error { limit.Cur = 1; return nil }
|
||||||
|
checkFDLimit()
|
||||||
|
connectGetrlimit = origGetrlimit
|
||||||
|
t.Cleanup(func() { connectGetrlimit = origGetrlimit })
|
||||||
|
|
||||||
|
origOpen, origRead, origRemove := connectLockOpenFile, connectLockReadFile, connectLockRemove
|
||||||
|
connectLockOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open") }
|
||||||
|
if _, err := acquireConnectLock("client"); err == nil || err.Error() != "open" {
|
||||||
|
t.Fatalf("non-exist lock err=%v", err)
|
||||||
|
}
|
||||||
|
connectLockOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, os.ErrExist }
|
||||||
|
connectLockReadFile = func(string) ([]byte, error) { return nil, errors.New("read") }
|
||||||
|
connectLockRemove = func(string) error { return errors.New("remove") }
|
||||||
|
if _, err := acquireConnectLock("client"); err == nil || !strings.Contains(err.Error(), "无法获取") {
|
||||||
|
t.Fatalf("terminal lock err=%v", err)
|
||||||
|
}
|
||||||
|
connectLockOpenFile, connectLockReadFile, connectLockRemove = origOpen, origRead, origRemove
|
||||||
|
t.Cleanup(func() { connectLockOpenFile, connectLockReadFile, connectLockRemove = origOpen, origRead, origRemove })
|
||||||
|
|
||||||
|
origReadAll, origReadFile := sheetCSVReadAll, sheetCSVReadFile
|
||||||
|
sheetCSVReadAll = func(io.Reader) ([]byte, error) { return nil, errors.New("stdin") }
|
||||||
|
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||||
|
if err := executeFilterCoverage(t, newDataCommandRoot(), "csv-put", "--node", "node", "--sheet-id", "sheet", "--start-cell", "A1", "--csv", "-"); err == nil {
|
||||||
|
t.Fatal("CSV stdin failure returned nil")
|
||||||
|
}
|
||||||
|
sheetCSVReadAll = origReadAll
|
||||||
|
csvFile := filepath.Join(t.TempDir(), "data.csv")
|
||||||
|
if err := os.WriteFile(csvFile, []byte("a,b"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := executeFilterCoverage(t, newDataCommandRoot(), "csv-put", "--node", "node", "--sheet-id", "sheet", "--start-cell", "A1", "--csv", "@"+csvFile); err != nil {
|
||||||
|
t.Fatalf("CSV file input: %v", err)
|
||||||
|
}
|
||||||
|
sheetCSVReadAll, sheetCSVReadFile = origReadAll, origReadFile
|
||||||
|
t.Cleanup(func() { sheetCSVReadAll, sheetCSVReadFile = origReadAll, origReadFile })
|
||||||
|
|
||||||
|
if err := executeFilterCoverage(t, newDataCommandRoot(), "append", "--node", "node", "--sheet-id", "sheet", "--values", "{"); err == nil {
|
||||||
|
t.Fatal("invalid append JSON returned nil")
|
||||||
|
}
|
||||||
|
floatRoot := &cobra.Command{Use: "sheet"}
|
||||||
|
floatRoot.AddCommand(newFloatImageCmds()...)
|
||||||
|
createFloat := findCoverageSubcommand(t, floatRoot, "create-float-image")
|
||||||
|
for _, name := range []string{"node", "sheet-id", "src", "range"} {
|
||||||
|
_ = createFloat.Flags().Set(name, "value")
|
||||||
|
}
|
||||||
|
createFloat.Flags().Lookup("width").Value = invalidIntFlagValue{}
|
||||||
|
if err := createFloat.RunE(createFloat, nil); err == nil {
|
||||||
|
t.Fatal("invalid float image width returned nil")
|
||||||
|
}
|
||||||
|
floatRoot = &cobra.Command{Use: "sheet"}
|
||||||
|
floatRoot.AddCommand(newFloatImageCmds()...)
|
||||||
|
createFloat = findCoverageSubcommand(t, floatRoot, "create-float-image")
|
||||||
|
for _, name := range []string{"node", "sheet-id", "src", "range"} {
|
||||||
|
_ = createFloat.Flags().Set(name, "value")
|
||||||
|
}
|
||||||
|
_ = createFloat.Flags().Set("width", "1")
|
||||||
|
createFloat.Flags().Lookup("height").Value = invalidIntFlagValue{}
|
||||||
|
if err := createFloat.RunE(createFloat, nil); err == nil {
|
||||||
|
t.Fatal("invalid float image height returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := newOaCommand()
|
||||||
|
approval := findCoverageSubcommand(t, root, "approval")
|
||||||
|
revoke := findCoverageSubcommand(t, approval, "revoke")
|
||||||
|
_ = revoke.Flags().Set("instance-id", "instance")
|
||||||
|
oldArgs, oldStdin := os.Args, os.Stdin
|
||||||
|
os.Args = []string{"dws"}
|
||||||
|
noPath := filepath.Join(t.TempDir(), "no")
|
||||||
|
if err := os.WriteFile(noPath, []byte("no\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stdin, err := os.Open(noPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Stdin = stdin
|
||||||
|
if err := revoke.RunE(revoke, nil); err != nil {
|
||||||
|
t.Fatalf("cancel revoke: %v", err)
|
||||||
|
}
|
||||||
|
os.Args, os.Stdin = oldArgs, oldStdin
|
||||||
|
_ = stdin.Close()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDataCommandRoot() *cobra.Command {
|
||||||
|
root := &cobra.Command{Use: "sheet"}
|
||||||
|
root.AddCommand(newDataCmds()...)
|
||||||
|
return root
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ func validateSegment(label, value string, minLen, maxLen int) error {
|
|||||||
if !namePattern.MatchString(value) {
|
if !namePattern.MatchString(value) {
|
||||||
return fmt.Errorf("%s %q must be kebab-case (a-z0-9-), starting with a letter", label, value)
|
return fmt.Errorf("%s %q must be kebab-case (a-z0-9-), starting with a letter", label, value)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(value, "-") || strings.HasSuffix(value, "-") {
|
if strings.HasSuffix(value, "-") {
|
||||||
return fmt.Errorf("%s %q must not start or end with a hyphen", label, value)
|
return fmt.Errorf("%s %q must not start or end with a hyphen", label, value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user