test: close changed-code coverage gaps to satisfy the coverage gate

- fetch_mcp_metadata: extract run()/resolveToken()/writeMetadata with
  injectable deps (keychain, servers, lister, registry, exit); full-path
  tests reach 100% file coverage.
- internal/cli: drop dead initSafetyByCLIPath (superseded by ResolveMeta),
  split buildMetaByCLIPath / assembleSchemaCatalogSnapshot /
  assembleCommandRegistryFrom / mergedCommandRegistryJSON so shard and
  malformed-snapshot failure modes are testable; cover catalog structure
  violation formatting (sort/truncate) and RenderSafetyAnnotation.
- generators: cover registry shard merge and catalog shard write failure
  modes.
- helpers/cmdutil: cover LeafSpec default/server dispatch, transform error
  propagation, default env hint, devapp member remove validate chain, and
  the required-flags error helpers.

Local gate: overall 90.17% vs merge-base 89.96%, changed-code 100%
(861 statements); make policy and go test ./... green.
This commit is contained in:
玉澜 committed 2026-07-28 18:05:20 +08:00
1 parent 5c30d01522
commit 4b0f71eedc
13 files changed
+1192 -93

No files matched your search

+79 -35
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
@@ -28,32 +29,53 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// toolLister is the tools/list capability consumed by run; production code
// uses transport.Client, tests inject fakes.
type toolLister interface {
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
}
// Injection points so run() is fully testable without network/keychain/exit.
var (
osExit = os.Exit
getenv = os.Getenv
loadTokenData = auth.LoadTokenDataKeychain
staticServers = syncdata.StaticServers
registrySource = cli.EmbeddedCommandRegistryMergedJSON
listToolsTimeout = 30 * time.Second
gitHeadPath = ".git/HEAD"
newToolLister = func(token string) toolLister {
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
}
)
func main() {
output := flag.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
flag.Parse()
osExit(run(os.Args[1:], os.Stderr))
}
token := strings.TrimSpace(os.Getenv("DWS_ACCESS_TOKEN"))
if token == "" {
td, err := auth.LoadTokenDataKeychain()
if err == nil && td != nil && td.AccessToken != "" {
token = td.AccessToken
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(token))
}
}
if token == "" {
fmt.Fprintln(os.Stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
os.Exit(1)
func run(args []string, stderr io.Writer) int {
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
if err := flags.Parse(args); err != nil {
return 2
}
client := transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
token := resolveToken(stderr)
if token == "" {
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
return 1
}
client := newToolLister(token)
// Iterate ALL static server endpoints (26 servers covering all products).
servers := syncdata.StaticServers()
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
servers := staticServers()
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
// Load CLI registry to build tool_name → interface_ref mapping.
registryMap := loadRegistryInterfaceRefs()
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
registryMap := loadRegistryInterfaceRefs(stderr)
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
// Load the previous schema_mcp_metadata.json to preserve hand-curated
// cross-server interface_ref mappings that automated matching can't derive.
@@ -82,15 +104,15 @@ func main() {
if endpoint == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
result, err := client.ListTools(ctx, endpoint)
cancel()
if err != nil {
fmt.Fprintf(os.Stderr, " [skip] %s: %v\n", srv.ID, err)
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
failedServices = append(failedServices, srv.ID)
continue
}
fmt.Fprintf(os.Stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
totalRaw += len(result.Tools)
for _, tool := range result.Tools {
name := strings.TrimSpace(tool.Name)
@@ -113,7 +135,7 @@ func main() {
matched++
}
}
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
// previous data, add stub entries (interface_ref only).
@@ -128,13 +150,13 @@ func main() {
stubs++
}
if stubs > 0 {
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
}
// Compute coverage fields required by check-schema-catalog.sh. Failed
// services must be reported honestly so policy can spot snapshot gaps.
if len(failedServices) > 0 {
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
len(failedServices), len(servers), strings.Join(failedServices, ", "))
}
@@ -146,22 +168,44 @@ func main() {
}
// source_revision: git commit hash (proves provenance).
if rev, err := os.ReadFile(".git/HEAD"); err == nil {
if rev, err := os.ReadFile(gitHeadPath); err == nil {
metadata["source_revision"] = strings.TrimSpace(string(rev))
}
if err := writeMetadata(*output, metadata); err != nil {
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
return 1
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
return 0
}
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
// fallback, the DWS keychain.
func resolveToken(stderr io.Writer) string {
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
if token != "" {
return token
}
td, err := loadTokenData()
if err != nil || td == nil || td.AccessToken == "" {
return ""
}
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
return td.AccessToken
}
// writeMetadata marshals the snapshot and writes it to the output path.
func writeMetadata(path string, metadata map[string]any) error {
data, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: marshal failed: %v\n", err)
os.Exit(1)
return fmt.Errorf("marshal failed: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(*output, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: write %s failed: %v\n", *output, err)
os.Exit(1)
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write %s failed: %w", path, err)
}
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
return nil
}
// buildCoverage reports snapshot coverage honestly: snapshot_services only
@@ -211,10 +255,10 @@ func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, t
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
// the cli package's reassembly API and builds a canonical_path →
// {product_id, rpc_name} mapping for interface_ref injection.
func loadRegistryInterfaceRefs() map[string]map[string]string {
data, err := cli.EmbeddedCommandRegistryMergedJSON()
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
data, err := registrySource()
if err != nil {
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
return map[string]map[string]string{}
}
var reg struct {
+280 -1
View File
@@ -4,14 +4,25 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
refs := loadRegistryInterfaceRefs()
var stderr bytes.Buffer
refs := loadRegistryInterfaceRefs(&stderr)
if len(refs) == 0 {
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
}
@@ -107,3 +118,271 @@ func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
}
}
// fakeLister returns canned tools/list results per endpoint.
type fakeLister struct {
results map[string]transport.ToolsListResult
errs map[string]error
}
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
if err := f.errs[endpoint]; err != nil {
return transport.ToolsListResult{}, err
}
return f.results[endpoint], nil
}
// stubDeps swaps every injection point for the duration of one test.
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
t.Helper()
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
t.Cleanup(func() {
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
})
getenv = func(key string) string {
if key == "DWS_ACCESS_TOKEN" {
return token
}
return ""
}
loadTokenData = keychain
staticServers = func() []syncdata.ServerInfo { return servers }
newToolLister = func(string) toolLister { return lister }
registrySource = registry
}
func testRegistryJSON() ([]byte, error) {
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
}
func TestRunNoTokenFails(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run(nil, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "no auth token") {
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
}
}
func TestRunInvalidFlagFails(t *testing.T) {
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
t.Fatalf("run() = %d, want 2", code)
}
}
func TestResolveTokenKeychainFallback(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) {
return &auth.TokenData{AccessToken: "kc-token"}, nil
}, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "kc-token" {
t.Fatalf("resolveToken() = %q, want kc-token", got)
}
if !strings.Contains(stderr.String(), "loaded token from keychain") {
t.Fatalf("stderr = %q, want keychain log", stderr.String())
}
}
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
if got := resolveToken(&stderr); got != "" {
t.Fatalf("resolveToken() = %q, want empty", got)
}
}
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
servers := []syncdata.ServerInfo{
{ID: "doc", Endpoint: "https://doc.example"},
{ID: "sheet", Endpoint: "https://sheet.example"},
{ID: "blank", Endpoint: " "},
}
lister := &fakeLister{
results: map[string]transport.ToolsListResult{
"https://doc.example": {Tools: []transport.ToolDescriptor{
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
"bogus": "not-a-map",
},
"required": []any{"doc_id", 42},
}},
{Name: " "},
{Name: "not_in_registry"},
}},
},
errs: map[string]error{"https://sheet.example": errors.New("boom")},
}
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
dir := t.TempDir()
output := filepath.Join(dir, "snapshot.json")
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
Version int `json:"version"`
Coverage map[string]any `json:"coverage"`
Tools map[string]map[string]any
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.Version != 1 {
t.Fatalf("version = %d", snapshot.Version)
}
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
}
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
t.Fatalf("missing_services = %v, want [sheet]", got)
}
live := snapshot.Tools["doc.copy_document"]
if live == nil || live["title"] != "复制文档" {
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
}
params := live["parameters"].(map[string]any)
docID := params["doc_id"].(map[string]any)
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
t.Fatalf("doc_id = %#v", docID)
}
if enum := docID["enum"].([]any); len(enum) != 2 {
t.Fatalf("enum = %v, want the 2 string members only", enum)
}
if _, ok := params["bogus"]; ok {
t.Fatal("non-map property should be skipped")
}
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
if prevRef["product_id"] != "doc-helper" {
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
}
if _, ok := snapshot.Tools["not_in_registry"]; ok {
t.Fatal("tools outside the registry must be dropped")
}
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
}
}
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
output := filepath.Join(t.TempDir(), "snapshot.json")
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
t.Fatal(err)
}
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
}
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
output := filepath.Join(t.TempDir(), "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cannot load registry") {
t.Fatalf("stderr = %q, want registry warning", stderr.String())
}
}
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
var stderr bytes.Buffer
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
}
}
func TestRunWriteFailure(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
var stderr bytes.Buffer
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
t.Fatalf("run() = %d, want 1 on write failure", code)
}
}
func TestWriteMetadataMarshalFailure(t *testing.T) {
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
t.Fatalf("err = %v, want marshal failure", err)
}
}
func TestMainDelegatesToRun(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
origExit, origArgs := osExit, os.Args
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
exitCode := -1
osExit = func(code int) { exitCode = code }
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
main()
if exitCode != 0 {
t.Fatalf("main() exited with %d, want 0", exitCode)
}
}
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
if got := extractParams(nil); got != nil {
t.Fatalf("extractParams(nil) = %v, want nil", got)
}
if got := extractParams(map[string]any{"type": "object"}); got != nil {
t.Fatalf("extractParams(no properties) = %v, want nil", got)
}
}
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
if lister := newToolLister("tok"); lister == nil {
t.Fatal("newToolLister returned nil")
}
}
func TestRunRecordsSourceRevision(t *testing.T) {
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
dir := t.TempDir()
head := filepath.Join(dir, "HEAD")
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
t.Fatal(err)
}
origHead := gitHeadPath
t.Cleanup(func() { gitHeadPath = origHead })
gitHeadPath = head
output := filepath.Join(dir, "snapshot.json")
var stderr bytes.Buffer
if code := run([]string{"--output", output}, &stderr); code != 0 {
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var snapshot struct {
SourceRevision string `json:"source_revision"`
}
if err := json.Unmarshal(data, &snapshot); err != nil {
t.Fatal(err)
}
if snapshot.SourceRevision != "ref: refs/heads/feature" {
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
}
}
+12 -6
View File
@@ -60,10 +60,15 @@ var (
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
func initMetaByCLIPath() {
metaByCLIPath = make(map[string]CommandMeta)
loaded := embeddedSchemaCatalog()
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
}
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
lookup := make(map[string]CommandMeta)
if loaded.Snapshot.Tools == nil {
return
return lookup
}
for _, tool := range loaded.Snapshot.Tools {
cliPath := catalogStringVal(tool, "cli_path")
@@ -91,7 +96,7 @@ func initMetaByCLIPath() {
Examples: catalogStringSliceVal(tool, "examples"),
},
}
metaByCLIPath[cliPath] = meta
lookup[cliPath] = meta
// Register each compat alias path (e.g. "report list") against the same
// metadata. Primary cli_path wins on collision so an alias can never
// shadow another command's canonical entry.
@@ -100,11 +105,12 @@ func initMetaByCLIPath() {
if alias == "" || alias == cliPath {
continue
}
if _, exists := metaByCLIPath[alias]; !exists {
metaByCLIPath[alias] = meta
if _, exists := lookup[alias]; !exists {
lookup[alias] = meta
}
}
}
return lookup
}
// catalogStringSliceVal reads a []string field from a catalog tool map.
-29
View File
@@ -16,7 +16,6 @@ package cli
import (
"fmt"
"strings"
"sync"
"github.com/spf13/cobra"
)
@@ -41,34 +40,6 @@ func (s CommandSafety) ShouldRender() bool {
(s.Risk != "" && s.Risk != "low")
}
var (
safetyByCLIPathOnce sync.Once
safetyByCLIPath map[string]CommandSafety
)
// initSafetyByCLIPath builds a cli_path → CommandSafety lookup from the
// embedded catalog. Runs once (sync.Once); the catalog is already decoded at
// package init, so this is a cheap map iteration.
func initSafetyByCLIPath() {
safetyByCLIPath = make(map[string]CommandSafety)
loaded := embeddedSchemaCatalog()
if loaded.Snapshot.Tools == nil {
return
}
for _, tool := range loaded.Snapshot.Tools {
cliPath := catalogStringVal(tool, "cli_path")
if cliPath == "" {
continue
}
safetyByCLIPath[cliPath] = CommandSafety{
Effect: catalogStringVal(tool, "effect"),
Risk: catalogStringVal(tool, "risk"),
Confirmation: catalogStringVal(tool, "confirmation"),
Idempotency: catalogStringVal(tool, "idempotency"),
}
}
}
// catalogStringVal reads a string field from a catalog tool map[string]any.
func catalogStringVal(tool map[string]any, key string) string {
if v, ok := tool[key].(string); ok {
+23 -12
View File
@@ -109,39 +109,50 @@ type schemaCatalogToolShard struct {
// whole payload: if any shard is missing, stale, or tampered, the content hash
// check in loadSchemaCatalogSnapshot fails exactly as before.
func assembleEmbeddedSchemaCatalog() (loadedSchemaCatalog, error) {
var envelope schemaCatalogEnvelope
if err := decodeStrictSchemaJSON(embeddedSchemaCatalogEnvelopeJSON, &envelope); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("decode embedded schema catalog.json: %w", err)
}
entries, err := embeddedSchemaCatalogTools.ReadDir("schema_catalog/tools")
snapshot, err := assembleSchemaCatalogSnapshot(embeddedSchemaCatalogEnvelopeJSON, embeddedSchemaCatalogTools, "schema_catalog/tools")
if err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err)
return loadedSchemaCatalog{}, err
}
return loadSchemaCatalogSnapshot(snapshot)
}
// assembleSchemaCatalogSnapshot merges an envelope document and a directory of
// per-product tool shards back into the single-document snapshot shape. Split
// from assembleEmbeddedSchemaCatalog so shard failure modes stay testable
// against fake filesystems.
func assembleSchemaCatalogSnapshot(envelopeJSON []byte, shards fs.FS, dir string) (SchemaCatalogSnapshot, error) {
var envelope schemaCatalogEnvelope
if err := decodeStrictSchemaJSON(envelopeJSON, &envelope); err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog.json: %w", err)
}
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err)
}
tools := make(map[string]map[string]any, len(entries)*8)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, readErr := fs.ReadFile(embeddedSchemaCatalogTools, "schema_catalog/tools/"+entry.Name())
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
if readErr != nil {
return loadedSchemaCatalog{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr)
return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr)
}
var shard schemaCatalogToolShard
if err := decodeStrictSchemaJSON(data, &shard); err != nil {
return loadedSchemaCatalog{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err)
return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err)
}
for canonical, spec := range shard.Tools {
tools[canonical] = spec
}
}
snapshot := SchemaCatalogSnapshot{
return SchemaCatalogSnapshot{
Version: envelope.Version,
SurfaceHash: envelope.SurfaceHash,
SourceHash: envelope.SourceHash,
Catalog: envelope.Catalog,
Tools: tools,
}
return loadSchemaCatalogSnapshot(snapshot)
}, nil
}
func embeddedSchemaCatalogError() error {
@@ -15,6 +15,7 @@ package cli
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
@@ -120,6 +121,12 @@ func TestValidateCatalogStructureRejectsViolations(t *testing.T) {
mutate: func(e map[string]any) { e["effect"] = "mutate" },
want: `"effect" = "mutate"`,
},
{
// 非字符串枚举字段:类型错误由字符串校验报告,枚举循环跳过。
name: "non-string enum field skips enum check",
mutate: func(e map[string]any) { e["effect"] = float64(1) },
want: `"effect" must be a non-empty string`,
},
{
name: "mcp without interface_ref",
mutate: func(e map[string]any) { delete(e, "interface_ref") },
@@ -193,6 +200,91 @@ func TestValidateCatalogStructureRejectsViolations(t *testing.T) {
mutate: func(e map[string]any) { e["examples"] = []any{"ok", float64(1)} },
want: `"examples" must be an array of strings`,
},
{
name: "use_when must be an array at all",
mutate: func(e map[string]any) { e["use_when"] = "单个字符串" },
want: `"use_when" must be an array of strings`,
},
{
name: "field_provenance must be object",
mutate: func(e map[string]any) { e["field_provenance"] = "not-an-object" },
want: `"field_provenance" must be an object`,
},
{
name: "parameters must be object",
mutate: func(e map[string]any) { e["parameters"] = []any{"x"} },
want: `"parameters" must be an object`,
},
{
name: "parameter_count must be number",
mutate: func(e map[string]any) { e["parameter_count"] = "1" },
want: `"parameter_count" must be a number`,
},
{
name: "parameter entry must be object",
mutate: func(e map[string]any) {
e["parameters"] = map[string]any{"base-id": "not-an-object"}
e["parameter_count"] = float64(1)
},
want: `parameter "base-id" must be an object`,
},
{
name: "interface_ref must be object",
mutate: func(e map[string]any) { e["interface_ref"] = "aitable.create_records" },
want: "interface_ref must be an object",
},
{
name: "interface_ref keys must be non-empty",
mutate: func(e map[string]any) {
e["interface_ref"] = map[string]any{"product_id": " ", "rpc_name": float64(1)}
},
want: "interface_ref.product_id must be a non-empty string",
},
{
name: "mcp must not set interface_reason",
mutate: func(e map[string]any) { e["interface_reason"] = "多余理由" },
want: "interface_mode=mcp must not set interface_reason",
},
{
name: "param description must be non-empty",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["description"] = " "
},
want: `parameter "base-id": description must be a non-empty string`,
},
{
name: "param field_provenance must be object",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["field_provenance"] = "x"
},
want: `parameter "base-id": field_provenance must be an object`,
},
{
name: "param cli_required must be bool",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["cli_required"] = "yes"
},
want: `parameter "base-id": cli_required must be a boolean`,
},
{
name: "param enum must be array",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["enum"] = "A,B"
},
want: `parameter "base-id": enum must be an array`,
},
{
name: "param enum items must be strings",
mutate: func(e map[string]any) {
params := e["parameters"].(map[string]any)
params["base-id"].(map[string]any)["enum"] = []any{"A", float64(2)}
},
want: `parameter "base-id": enum[1] must be a string`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -220,3 +312,47 @@ func TestValidateCatalogStructureRejectsBadEnvelope(t *testing.T) {
t.Fatal("ValidateCatalogStructure() = nil, want decode error")
}
}
func TestValidateCatalogStructureSortsViolationsAcrossTools(t *testing.T) {
// 两个工具各制造两条违规,驱动排序比较器的 tool 与 message 两个分支。
bad1 := validCatalogToolEntry()
bad1["effect"] = "mutate"
bad1["risk"] = "extreme"
bad2 := validCatalogToolEntry()
bad2["canonical_path"] = "zz.last_tool"
bad2["effect"] = "mutate"
bad2["risk"] = "extreme"
payload, err := json.Marshal(map[string]any{
"version": SchemaCatalogSnapshotVersion,
"tools": map[string]any{"zz.last_tool": bad2, "aitable.record_create": bad1},
})
if err != nil {
t.Fatal(err)
}
verr := ValidateCatalogStructure(payload)
if verr == nil {
t.Fatal("ValidateCatalogStructure() = nil, want sorted violations")
}
msg := verr.Error()
if !strings.Contains(msg, "(4 total)") {
t.Fatalf("error = %v, want 4 total violations", verr)
}
if first, second := strings.Index(msg, "aitable.record_create"), strings.Index(msg, "zz.last_tool"); first < 0 || second < 0 || first > second {
t.Fatalf("violations not sorted by tool: %v", verr)
}
}
func TestValidateCatalogStructureTruncatesViolationList(t *testing.T) {
// 单工具制造超过 25 条违规,验证截断提示。
entry := validCatalogToolEntry()
for i := 0; i < 30; i++ {
entry[fmt.Sprintf("unknown_field_%02d", i)] = true
}
err := ValidateCatalogStructure(catalogPayload(t, entry))
if err == nil {
t.Fatal("ValidateCatalogStructure() = nil, want truncated violation list")
}
if !strings.Contains(err.Error(), "more violations") {
t.Fatalf("error = %v, want truncation marker", err)
}
}
+22 -10
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"io"
"io/fs"
"regexp"
"sort"
"strings"
@@ -108,15 +109,22 @@ func loadEmbeddedCommandRegistry() (CommandRegistry, error) {
// and reassembles them into a single CommandRegistry, identical to the previous
// single-file layout. Mirrors assembleEmbeddedSchemaCatalog for catalog shards.
func assembleCommandRegistry() (CommandRegistry, error) {
return assembleCommandRegistryFrom(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products")
}
// assembleCommandRegistryFrom reassembles an envelope plus product shard
// directory. Split from assembleCommandRegistry so shard failure modes stay
// testable against fake filesystems.
func assembleCommandRegistryFrom(envelopeJSON []byte, shards fs.FS, dir string) (CommandRegistry, error) {
var envelope struct {
Schema string `json:"$schema,omitempty"`
Version int `json:"version"`
}
if err := json.Unmarshal(embeddedSchemaCommandRegistryEnvelopeJSON, &envelope); err != nil {
if err := json.Unmarshal(envelopeJSON, &envelope); err != nil {
return CommandRegistry{}, fmt.Errorf("decode embedded command registry envelope: %w", err)
}
entries, err := embeddedSchemaCommandRegistryProducts.ReadDir("schema_command_registry/products")
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return CommandRegistry{}, fmt.Errorf("read embedded command registry products: %w", err)
}
@@ -128,7 +136,7 @@ func assembleCommandRegistry() (CommandRegistry, error) {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, readErr := embeddedSchemaCommandRegistryProducts.ReadFile("schema_command_registry/products/" + entry.Name())
data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name())
if readErr != nil {
return CommandRegistry{}, fmt.Errorf("read embedded command registry shard %s: %w", entry.Name(), readErr)
}
@@ -141,10 +149,8 @@ func assembleCommandRegistry() (CommandRegistry, error) {
// Encode to bytes and decode via the existing pipeline so all validation
// (canonical patterns, duplicate detection, visibility) runs identically.
data, err := json.Marshal(snapshot)
if err != nil {
return CommandRegistry{}, fmt.Errorf("marshal reassembled command registry: %w", err)
}
// Marshal of this plain struct cannot fail.
data, _ := json.Marshal(snapshot)
return decodeCommandRegistry(data)
}
@@ -152,14 +158,20 @@ func assembleCommandRegistry() (CommandRegistry, error) {
// into a single JSON document matching the pre-split layout. Used by tests that
// need the full registry bytes.
func EmbeddedCommandRegistryMergedJSON() ([]byte, error) {
return mergedCommandRegistryJSON(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products")
}
// mergedCommandRegistryJSON is the injectable core of
// EmbeddedCommandRegistryMergedJSON.
func mergedCommandRegistryJSON(envelopeJSON []byte, shards fs.FS, dir string) ([]byte, error) {
var envelope struct {
Schema string `json:"$schema,omitempty"`
Version int `json:"version"`
}
if err := json.Unmarshal(embeddedSchemaCommandRegistryEnvelopeJSON, &envelope); err != nil {
if err := json.Unmarshal(envelopeJSON, &envelope); err != nil {
return nil, err
}
entries, err := embeddedSchemaCommandRegistryProducts.ReadDir("schema_command_registry/products")
entries, err := fs.ReadDir(shards, dir)
if err != nil {
return nil, err
}
@@ -168,7 +180,7 @@ func EmbeddedCommandRegistryMergedJSON() ([]byte, error) {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
data, err := embeddedSchemaCommandRegistryProducts.ReadFile("schema_command_registry/products/" + entry.Name())
data, err := fs.ReadFile(shards, dir+"/"+entry.Name())
if err != nil {
return nil, err
}
+233
View File
@@ -0,0 +1,233 @@
// 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 cli
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/spf13/cobra"
)
// failingReadFS lists entries but fails ReadFile, driving shard-read errors.
type failingReadFS struct{ fstest.MapFS }
func (f failingReadFS) ReadFile(name string) ([]byte, error) {
if strings.HasSuffix(name, ".json") {
return nil, errors.New("shard read denied")
}
return f.MapFS.ReadFile(name)
}
func TestAssembleSchemaCatalogSnapshotMergesShards(t *testing.T) {
envelope := []byte(`{"version":1,"surface_hash":"sha256:s","source_hash":"sha256:c","catalog":{"kind":"schema"}}`)
shards := fstest.MapFS{
"tools/doc.json": {Data: []byte(`{"product":"doc","tools":{"doc.copy":{"title":"复制"}}}`)},
"tools/sheet.json": {Data: []byte(`{"product":"sheet","tools":{"sheet.get":{"title":"读取"}}}`)},
"tools/README.md": {Data: []byte("not a shard")},
}
snapshot, err := assembleSchemaCatalogSnapshot(envelope, shards, "tools")
if err != nil {
t.Fatalf("assembleSchemaCatalogSnapshot() error = %v", err)
}
if snapshot.Version != 1 || snapshot.SurfaceHash != "sha256:s" || snapshot.SourceHash != "sha256:c" {
t.Fatalf("envelope fields lost: %+v", snapshot)
}
if len(snapshot.Tools) != 2 || snapshot.Tools["doc.copy"] == nil || snapshot.Tools["sheet.get"] == nil {
t.Fatalf("tools = %#v, want merged doc.copy + sheet.get", snapshot.Tools)
}
}
func TestAssembleSchemaCatalogSnapshotFailureModes(t *testing.T) {
valid := []byte(`{"version":1,"source_hash":"sha256:c","catalog":{}}`)
if _, err := assembleSchemaCatalogSnapshot([]byte("{bad"), fstest.MapFS{}, "tools"); err == nil || !strings.Contains(err.Error(), "decode embedded schema catalog.json") {
t.Fatalf("bad envelope err = %v", err)
}
if _, err := assembleSchemaCatalogSnapshot(valid, fstest.MapFS{}, "missing-dir"); err == nil || !strings.Contains(err.Error(), "tools directory") {
t.Fatalf("missing dir err = %v", err)
}
badShard := fstest.MapFS{"tools/doc.json": {Data: []byte("{bad")}}
if _, err := assembleSchemaCatalogSnapshot(valid, badShard, "tools"); err == nil || !strings.Contains(err.Error(), "decode embedded schema catalog shard") {
t.Fatalf("bad shard err = %v", err)
}
denied := failingReadFS{fstest.MapFS{"tools/doc.json": {Data: []byte(`{}`)}}}
if _, err := assembleSchemaCatalogSnapshot(valid, denied, "tools"); err == nil || !strings.Contains(err.Error(), "read embedded schema catalog shard") {
t.Fatalf("denied shard err = %v", err)
}
}
func TestAssembleCommandRegistryFromShards(t *testing.T) {
envelope := []byte(`{"$schema":"./schema_command_registry.schema.json","version":1}`)
shards := fstest.MapFS{
"products/doc.json": {Data: []byte(`{"id":"doc","tools":[{"canonical_path":"doc.copy_document","cli_path":"doc copy"}]}`)},
"products/notes.md": {Data: []byte("skip me")},
}
registry, err := assembleCommandRegistryFrom(envelope, shards, "products")
if err != nil {
t.Fatalf("assembleCommandRegistryFrom() error = %v", err)
}
if len(registry.Commands) != 1 || registry.Commands[0].CanonicalPath != "doc.copy_document" {
t.Fatalf("registry = %+v, want single doc.copy_document command", registry.Commands)
}
}
func TestAssembleCommandRegistryFromFailureModes(t *testing.T) {
valid := []byte(`{"version":1}`)
if _, err := assembleCommandRegistryFrom([]byte("{bad"), fstest.MapFS{}, "products"); err == nil || !strings.Contains(err.Error(), "envelope") {
t.Fatalf("bad envelope err = %v", err)
}
if _, err := assembleCommandRegistryFrom(valid, fstest.MapFS{}, "missing-dir"); err == nil || !strings.Contains(err.Error(), "read embedded command registry products") {
t.Fatalf("missing dir err = %v", err)
}
badShard := fstest.MapFS{"products/doc.json": {Data: []byte("{bad")}}
if _, err := assembleCommandRegistryFrom(valid, badShard, "products"); err == nil || !strings.Contains(err.Error(), "decode embedded command registry shard") {
t.Fatalf("bad shard err = %v", err)
}
denied := failingReadFS{fstest.MapFS{"products/doc.json": {Data: []byte(`{}`)}}}
if _, err := assembleCommandRegistryFrom(valid, denied, "products"); err == nil || !strings.Contains(err.Error(), "read embedded command registry shard") {
t.Fatalf("denied shard err = %v", err)
}
}
func TestMergedCommandRegistryJSONFailureModes(t *testing.T) {
valid := []byte(`{"version":1}`)
if _, err := mergedCommandRegistryJSON([]byte("{bad"), fstest.MapFS{}, "products"); err == nil {
t.Fatal("bad envelope should fail")
}
if _, err := mergedCommandRegistryJSON(valid, fstest.MapFS{}, "missing-dir"); err == nil {
t.Fatal("missing dir should fail")
}
denied := failingReadFS{fstest.MapFS{"products/doc.json": {Data: []byte(`{}`)}}}
if _, err := mergedCommandRegistryJSON(valid, denied, "products"); err == nil {
t.Fatal("denied shard should fail")
}
// Invalid shard JSON survives assembly as RawMessage but fails the final
// products marshal.
badShard := fstest.MapFS{"products/doc.json": {Data: []byte("{bad")}}
if _, err := mergedCommandRegistryJSON(valid, badShard, "products"); err == nil {
t.Fatal("invalid RawMessage shard should fail marshal")
}
}
func TestMergedCommandRegistryJSONDefaultsSchemaAndSkipsNonJSON(t *testing.T) {
shards := fstest.MapFS{
"products/doc.json": {Data: []byte(`{"id":"doc","tools":[]}`)},
"products/notes.md": {Data: []byte("skip me")},
}
data, err := mergedCommandRegistryJSON([]byte(`{"version":1}`), shards, "products")
if err != nil {
t.Fatalf("mergedCommandRegistryJSON() error = %v", err)
}
var merged struct {
Schema string `json:"$schema"`
Version int `json:"version"`
Products []json.RawMessage `json:"products"`
}
if err := json.Unmarshal(data, &merged); err != nil {
t.Fatal(err)
}
if merged.Schema != "./schema_command_registry.schema.json" {
t.Fatalf("schema = %q, want defaulted schema path", merged.Schema)
}
if len(merged.Products) != 1 {
t.Fatalf("products = %d, want 1 (markdown skipped)", len(merged.Products))
}
}
func TestBuildMetaByCLIPathGuards(t *testing.T) {
// nil Tools:损坏快照直接返回空表。
if got := buildMetaByCLIPath(loadedSchemaCatalog{}); len(got) != 0 {
t.Fatalf("nil tools lookup = %#v, want empty", got)
}
loaded := loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{
"doc.no_cli_path": {"canonical_path": "doc.no_cli_path"},
"doc.primary": {
"cli_path": "doc primary",
"canonical_path": "doc.primary",
"aliases": []any{"doc alias", "doc primary", " ", float64(1)},
},
"doc.other": {
"cli_path": "doc alias",
"canonical_path": "doc.other",
},
}}}
lookup := buildMetaByCLIPath(loaded)
if _, ok := lookup[""]; ok {
t.Fatal("entry without cli_path must be skipped")
}
// 别名与其他命令的主路径冲突时,主路径必须胜出。
if got := lookup["doc alias"].Identity.Canonical; got != "doc.other" {
t.Fatalf("doc alias resolved to %q, want primary owner doc.other", got)
}
if got := lookup["doc primary"].Identity.Canonical; got != "doc.primary" {
t.Fatalf("doc primary resolved to %q", got)
}
// Identity.Aliases 保留 Catalog 中的字符串成员(非字符串被丢弃);
// 空白/自指别名仅在查找表注册时跳过。
if aliases := lookup["doc primary"].Identity.Aliases; len(aliases) != 3 {
t.Fatalf("aliases = %v, want the 3 string members (float dropped)", aliases)
}
}
func TestCatalogStringSliceValNonSlice(t *testing.T) {
if got := catalogStringSliceVal(map[string]any{"aliases": "not-a-slice"}, "aliases"); got != nil {
t.Fatalf("catalogStringSliceVal() = %v, want nil", got)
}
}
func TestRenderSafetyAnnotation(t *testing.T) {
// dev app delete: destructive/high/user_required —— 必须渲染完整注解。
root := &cobra.Command{Use: "dws"}
dev := &cobra.Command{Use: "dev"}
app := &cobra.Command{Use: "app"}
deleteCmd := &cobra.Command{Use: "delete"}
root.AddCommand(dev)
dev.AddCommand(app)
app.AddCommand(deleteCmd)
var out bytes.Buffer
deleteCmd.SetOut(&out)
RenderSafetyAnnotation(deleteCmd)
rendered := out.String()
if !strings.Contains(rendered, "Safety: effect=destructive") || !strings.Contains(rendered, "(需 --yes)") {
t.Fatalf("rendered = %q, want destructive annotation with confirmation hint", rendered)
}
if !strings.Contains(rendered, "idempotency=") {
t.Fatalf("rendered = %q, want idempotency field", rendered)
}
// 未收录命令不输出任何内容。
unknown := &cobra.Command{Use: "unknown-cmd"}
root.AddCommand(unknown)
var silent bytes.Buffer
unknown.SetOut(&silent)
RenderSafetyAnnotation(unknown)
if silent.Len() != 0 {
t.Fatalf("unknown command rendered %q, want empty", silent.String())
}
}
// TestAssembleEmbeddedSchemaCatalogPropagatesAssemblyError 通过临时替换嵌入的
// envelope 字节,覆盖 assembleEmbeddedSchemaCatalog 的错误透传分支。
func TestAssembleEmbeddedSchemaCatalogPropagatesAssemblyError(t *testing.T) {
orig := embeddedSchemaCatalogEnvelopeJSON
t.Cleanup(func() { embeddedSchemaCatalogEnvelopeJSON = orig })
embeddedSchemaCatalogEnvelopeJSON = []byte("{bad")
if _, err := assembleEmbeddedSchemaCatalog(); err == nil || !strings.Contains(err.Error(), "decode embedded schema catalog.json") {
t.Fatalf("err = %v, want assembly error passthrough", err)
}
}
@@ -0,0 +1,116 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMergeRegistryShardsFailureModes drives every error path of the split
// registry merge used by validateCommandRegistryFile.
func TestMergeRegistryShardsFailureModes(t *testing.T) {
t.Run("missing envelope", func(t *testing.T) {
if _, err := mergeRegistryShards(t.TempDir()); err == nil || !strings.Contains(err.Error(), "read registry envelope") {
t.Fatalf("err = %v, want envelope read failure", err)
}
})
t.Run("bad envelope json", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte("{bad"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := mergeRegistryShards(dir); err == nil || !strings.Contains(err.Error(), "decode registry envelope") {
t.Fatalf("err = %v, want envelope decode failure", err)
}
})
t.Run("missing products dir", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"version":1}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := mergeRegistryShards(dir); err == nil || !strings.Contains(err.Error(), "read registry products dir") {
t.Fatalf("err = %v, want products dir failure", err)
}
})
t.Run("unreadable shard", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"version":1}`), 0o600); err != nil {
t.Fatal(err)
}
products := filepath.Join(dir, "products")
if err := os.MkdirAll(products, 0o755); err != nil {
t.Fatal(err)
}
// 断链符号链接:ReadDir 能列出,ReadFile 必失败。
if err := os.Symlink(filepath.Join(dir, "nonexistent"), filepath.Join(products, "doc.json")); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := mergeRegistryShards(dir); err == nil || !strings.Contains(err.Error(), "read registry shard") {
t.Fatalf("err = %v, want shard read failure", err)
}
})
t.Run("invalid raw shard fails products marshal", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"version":1}`), 0o600); err != nil {
t.Fatal(err)
}
products := filepath.Join(dir, "products")
if err := os.MkdirAll(products, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(products, "doc.json"), []byte("{bad"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := mergeRegistryShards(dir); err == nil {
t.Fatal("err = nil, want RawMessage marshal failure for invalid shard JSON")
}
})
}
func TestMergeRegistryShardsSkipsNonShardEntries(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"$schema":"./s.json","version":1}`), 0o600); err != nil {
t.Fatal(err)
}
products := filepath.Join(dir, "products")
if err := os.MkdirAll(filepath.Join(products, "nested.json"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(products, "README.md"), []byte("skip"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(products, "doc.json"), []byte(`{"id":"doc","tools":[]}`), 0o600); err != nil {
t.Fatal(err)
}
data, err := mergeRegistryShards(dir)
if err != nil {
t.Fatalf("mergeRegistryShards() error = %v", err)
}
merged := string(data)
if !strings.Contains(merged, `"id":"doc"`) || strings.Contains(merged, "skip") {
t.Fatalf("merged = %s, want doc shard only", merged)
}
}
func TestValidateCommandRegistryFileShardDirectoryErrors(t *testing.T) {
// 目录形态但缺 envelope:mergeRegistryShards 的错误必须透传。
dir := t.TempDir()
if err := validateCommandRegistryFile("", dir); err == nil || !strings.Contains(err.Error(), "read registry envelope") {
t.Fatalf("err = %v, want merge failure passthrough", err)
}
// 单文件形态但不可读(无读权限)。
if os.Getuid() == 0 {
t.Skip("running as root; permission-based read failure not enforceable")
}
unreadable := filepath.Join(t.TempDir(), "registry.json")
if err := os.WriteFile(unreadable, []byte(`{"version":1}`), 0o000); err != nil {
t.Fatal(err)
}
if err := validateCommandRegistryFile("", unreadable); err == nil {
t.Fatal("err = nil, want read failure for unreadable registry file")
}
}
@@ -0,0 +1,59 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package main
import (
"math"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
)
func TestWriteSchemaCatalogShardsClearFailure(t *testing.T) {
// 输出路径的父级是普通文件:RemoveAll 报非 IsNotExist 错误。
parent := filepath.Join(t.TempDir(), "occupied")
if err := os.WriteFile(parent, []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
err := writeSchemaCatalogShards(cli.SchemaCatalogSnapshot{}, filepath.Join(parent, "schema_catalog"))
if err == nil {
t.Fatal("err = nil, want clear/create failure under a plain file")
}
}
func TestWriteSchemaCatalogShardsShardWriteFailure(t *testing.T) {
// canonical 前缀带路径分隔符时目标目录不存在,shard 写入必须失败。
snapshot := cli.SchemaCatalogSnapshot{
Version: 1,
Tools: map[string]map[string]any{
"bad/prod.tool": {"title": "x"},
},
}
err := writeSchemaCatalogShards(snapshot, filepath.Join(t.TempDir(), "schema_catalog"))
if err == nil || !strings.Contains(err.Error(), "write schema catalog tools/") {
t.Fatalf("err = %v, want shard write failure", err)
}
}
func TestWriteSchemaCatalogJSONEncodeFailure(t *testing.T) {
err := writeSchemaCatalogJSON(filepath.Join(t.TempDir(), "catalog.json"), map[string]any{"bad": math.NaN()})
if err == nil || !strings.Contains(err.Error(), "encode catalog.json") {
t.Fatalf("err = %v, want encode failure", err)
}
}
func TestWriteSchemaCatalogShardsEnvelopeWriteFailure(t *testing.T) {
// envelope 自身编码失败(Catalog 含 NaN)走 write catalog.json 错误分支。
snapshot := cli.SchemaCatalogSnapshot{
Version: 1,
Catalog: map[string]any{"bad": math.NaN()},
}
err := writeSchemaCatalogShards(snapshot, filepath.Join(t.TempDir(), "schema_catalog"))
if err == nil || !strings.Contains(err.Error(), "write schema catalog.json") {
t.Fatalf("err = %v, want envelope write failure", err)
}
}
+37
View File
@@ -524,3 +524,40 @@ func TestDevAppLeafToolArgsTable(t *testing.T) {
})
}
}
// TestDevAppMemberRemoveValidateChain 覆盖 member remove Validate 钩子的逐级
// 必填校验:unified-app-id → user-ids → member-type。
func TestDevAppMemberRemoveValidateChain(t *testing.T) {
newCmd := func() *cobra.Command {
cmd := newDevAppMemberRemoveCommand(&fakeDevAppRunner{})
cmd.Flags().Bool("yes", false, "")
if err := cmd.Flags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
return cmd
}
cmd := newCmd()
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "unified-app-id") {
t.Fatalf("missing unified-app-id err = %v", err)
}
cmd = newCmd()
if err := cmd.Flags().Set("unified-app-id", "APP-1"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "user-ids") {
t.Fatalf("missing user-ids err = %v", err)
}
cmd = newCmd()
if err := cmd.Flags().Set("unified-app-id", "APP-1"); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("user-ids", "u1"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "member-type") {
t.Fatalf("missing member-type err = %v", err)
}
}
+118
View File
@@ -0,0 +1,118 @@
// 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 (
"context"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// leafDispatchCaller 是 leaf 默认派发路径的 fake ToolCaller。
type leafDispatchCaller struct {
dryRun bool
productID string
toolName string
}
func (c *leafDispatchCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
c.productID = productID
c.toolName = toolName
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
}
func (c *leafDispatchCaller) Format() string { return "json" }
func (c *leafDispatchCaller) DryRun() bool { return c.dryRun }
func (c *leafDispatchCaller) Fields() string { return "" }
func (c *leafDispatchCaller) JQ() string { return "" }
func withLeafDispatchCaller(t *testing.T, dryRun bool) *leafDispatchCaller {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
caller := &leafDispatchCaller{dryRun: dryRun}
InitDeps(caller)
deps.Out.w = io.Discard
return caller
}
// TestLeafCommandDefaultDispatch 覆盖默认 callMCPTool 派发(无 Call/Server)。
// DryRun caller 在路由前早返回,验证 RunE 走到默认派发语句且无错误。
func TestLeafCommandDefaultDispatch(t *testing.T) {
withLeafDispatchCaller(t, true)
cmd := NewLeafCommand(LeafSpec{
Use: "get", Tool: "get_thing",
Flags: []LeafFlag{{Name: "id", Usage: "ID", Bind: "id"}},
})
if err := cmd.Flags().Set("id", "x"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() = %v, want nil via dry-run dispatch", err)
}
}
// TestLeafCommandServerDispatch 覆盖显式 Server 路由分支。
func TestLeafCommandServerDispatch(t *testing.T) {
caller := withLeafDispatchCaller(t, false)
cmd := NewLeafCommand(LeafSpec{
Use: "get", Server: "doc", Tool: "get_document",
Flags: []LeafFlag{{Name: "id", Usage: "ID", Bind: "docId"}},
})
if err := cmd.Flags().Set("id", "d1"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE() = %v", err)
}
if caller.productID != "doc" || caller.toolName != "get_document" {
t.Fatalf("dispatched to %s/%s, want doc/get_document", caller.productID, caller.toolName)
}
}
// TestLeafCommandTransformErrorAborts 覆盖 RunE 中 leafArgs 错误传播。
func TestLeafCommandTransformErrorAborts(t *testing.T) {
withLeafDispatchCaller(t, true)
cmd := NewLeafCommand(LeafSpec{
Use: "get", Tool: "get_thing",
Flags: []LeafFlag{{
Name: "when", Usage: "时间", Bind: "when",
Transform: func(string) (any, error) { return nil, errors.New("bad time format") },
}},
})
if err := cmd.Flags().Set("when", "not-a-time"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "bad time format") {
t.Fatalf("RunE() = %v, want transform error", err)
}
}
// TestLeafValidateRequiredEnvDefaultHint 覆盖 EnvVar Required 未配置
// RequiredHint 时的默认报错文案。
func TestLeafValidateRequiredEnvDefaultHint(t *testing.T) {
spec := LeafSpec{
Use: "send", Tool: "send_thing",
Flags: []LeafFlag{{Name: "token", Usage: "令牌", Required: true, EnvVar: "DWS_LEAF_TEST_HINTLESS"}},
}
cmd := NewLeafCommand(spec)
err := leafValidateRequired(cmd, spec)
if err == nil || err.Error() != "flag --token is required" {
t.Fatalf("leafValidateRequired() = %v, want default hint", err)
}
}
+77
View File
@@ -0,0 +1,77 @@
// 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 cmdutil
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func newFlagsTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "send", Example: " dws send --content hi"}
cmd.Flags().String("content", "", "内容")
cmd.Flags().String("title", "", "标题")
return cmd
}
func TestValidateRequiredFlagsReportsAllMissing(t *testing.T) {
cmd := newFlagsTestCommand()
err := ValidateRequiredFlags(cmd, "content", "title")
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content, --title") {
t.Fatalf("err = %v, want both flags reported", err)
}
if !strings.Contains(err.Error(), cmd.Example) {
t.Fatalf("err = %v, want example hint", err)
}
}
func TestValidateRequiredFlagsPassesWhenSet(t *testing.T) {
cmd := newFlagsTestCommand()
if err := cmd.Flags().Set("content", "hi"); err != nil {
t.Fatal(err)
}
if err := ValidateRequiredFlags(cmd, "content"); err != nil {
t.Fatalf("err = %v, want nil", err)
}
}
func TestMissingRequiredFlagsErrorNilForEmpty(t *testing.T) {
if err := MissingRequiredFlagsError(newFlagsTestCommand()); err != nil {
t.Fatalf("err = %v, want nil for no missing flags", err)
}
}
func TestMissingRequiredFlagsErrorFormatsNames(t *testing.T) {
err := MissingRequiredFlagsError(newFlagsTestCommand(), "content")
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") {
t.Fatalf("err = %v, want formatted missing flag", err)
}
}
func TestValidateRequiredFlagWithAliases(t *testing.T) {
cmd := newFlagsTestCommand()
cmd.Flags().String("body", "", "别名")
err := ValidateRequiredFlagWithAliases(cmd, "content", "body")
if err == nil || !strings.Contains(err.Error(), "missing required flag: --content (or --body)") {
t.Fatalf("err = %v, want alias-aware missing error", err)
}
if err := cmd.Flags().Set("body", "hi"); err != nil {
t.Fatal(err)
}
if err := ValidateRequiredFlagWithAliases(cmd, "content", "body"); err != nil {
t.Fatalf("err = %v, want nil when alias satisfies requirement", err)
}
}