* chore: update coverage badge [skip ci] * chore: update coverage badge [skip ci] * chore: update coverage badge [skip ci] * fix conference discovery --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
193 lines
6.1 KiB
Go
193 lines
6.1 KiB
Go
// 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 app
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
)
|
|
|
|
// supplementOnlyCatalogLoader mimics the post-fix EnvironmentLoader: the
|
|
// catalog has the product entry (materialised from SupplementServers) but
|
|
// no tool list — the overlay owns the tool tree locally.
|
|
type supplementOnlyCatalogLoader struct{}
|
|
|
|
func (supplementOnlyCatalogLoader) Load(_ context.Context) (ir.Catalog, error) {
|
|
return ir.Catalog{
|
|
Products: []ir.CanonicalProduct{
|
|
{
|
|
ID: "conference",
|
|
ServerKey: "conference",
|
|
Endpoint: "stdio://conference-catalog",
|
|
Tools: nil,
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func resetDynamicServers(t *testing.T) {
|
|
t.Helper()
|
|
orig := snapshotDynamicServers()
|
|
t.Cleanup(func() { restoreDynamicServers(orig) })
|
|
}
|
|
|
|
type dynamicServerSnapshot struct {
|
|
endpoints map[string]string
|
|
products map[string]bool
|
|
aliases map[string]string
|
|
toolEndpoints map[string]string
|
|
}
|
|
|
|
func snapshotDynamicServers() dynamicServerSnapshot {
|
|
dynamicMu.RLock()
|
|
defer dynamicMu.RUnlock()
|
|
return dynamicServerSnapshot{
|
|
endpoints: cloneStringMap(dynamicEndpoints),
|
|
products: cloneBoolMap(dynamicProducts),
|
|
aliases: cloneStringMap(dynamicAliases),
|
|
toolEndpoints: cloneStringMap(dynamicToolEndpoints),
|
|
}
|
|
}
|
|
|
|
func restoreDynamicServers(s dynamicServerSnapshot) {
|
|
dynamicMu.Lock()
|
|
defer dynamicMu.Unlock()
|
|
dynamicEndpoints = s.endpoints
|
|
dynamicProducts = s.products
|
|
dynamicAliases = s.aliases
|
|
dynamicToolEndpoints = s.toolEndpoints
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneBoolMap(in map[string]bool) map[string]bool {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime pins the runner's
|
|
// bridge between the catalog path (where a product entry can come from
|
|
// SupplementServers with no tool list) and the direct-runtime path (which
|
|
// carries the authoritative per-tool endpoint map). When the catalog knows
|
|
// the product but not the tool, the runner should not fail-fast with
|
|
// endpoint_not_resolved — it should consult dynamicEndpoints one more time
|
|
// and proceed if an endpoint is registered.
|
|
//
|
|
// This is the narrow recovery path that keeps hardcoded overlay commands
|
|
// working under a gray-released envelope: the supplement-materialised
|
|
// catalog entry has endpoint+no tools, and SetDynamicServers holds the
|
|
// operational endpoint indexed by product / command.
|
|
func TestRuntimeRunner_ToolMiss_FallsBackToDirectRuntime(t *testing.T) {
|
|
resetDynamicServers(t)
|
|
SetDynamicServers([]market.ServerDescriptor{
|
|
{
|
|
Key: "conference",
|
|
DisplayName: "会议",
|
|
Endpoint: "stdio://conference-fake",
|
|
CLI: market.CLIOverlay{
|
|
ID: "conference",
|
|
Command: "conference",
|
|
},
|
|
Source: "edition_supplement",
|
|
},
|
|
})
|
|
|
|
runner := &runtimeRunner{
|
|
loader: supplementOnlyCatalogLoader{},
|
|
transport: transport.NewClient(nil),
|
|
fallback: executor.EchoRunner{},
|
|
}
|
|
|
|
// Kind = api_invocation forces the code to skip the Run() opening
|
|
// direct-runtime attempt and go through the catalog path instead, so
|
|
// the tool-miss recovery branch we're testing actually runs.
|
|
inv := executor.Invocation{
|
|
Kind: "api_invocation",
|
|
CanonicalProduct: "conference",
|
|
Tool: "create_meeting_reservation",
|
|
CanonicalPath: "conference.create_meeting_reservation",
|
|
DryRun: true,
|
|
Params: map[string]any{},
|
|
}
|
|
|
|
result, err := runner.Run(context.Background(), inv)
|
|
if err != nil {
|
|
t.Fatalf("runner.Run returned error, want tool-miss fallback success: %v", err)
|
|
}
|
|
if result.Response == nil {
|
|
t.Fatalf("expected non-nil Response on dry-run")
|
|
}
|
|
if got, _ := result.Response["dry_run"].(bool); !got {
|
|
t.Fatalf("expected dry_run=true in Response, got %v", result.Response)
|
|
}
|
|
if got, _ := result.Response["transport"].(string); got != "stdio" {
|
|
t.Fatalf("expected transport=stdio in Response (proof we hit stdio://conference-fake), got %v", result.Response)
|
|
}
|
|
}
|
|
|
|
// TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed is the inverse
|
|
// guard: when both the catalog tool list and dynamicEndpoints have no
|
|
// record for the requested tool, the runner must still surface
|
|
// endpoint_not_resolved instead of silently producing empty output.
|
|
func TestRuntimeRunner_ToolMiss_NoDynamicEntry_StillFailsClosed(t *testing.T) {
|
|
resetDynamicServers(t)
|
|
SetDynamicServers([]market.ServerDescriptor{}) // intentionally empty
|
|
|
|
runner := &runtimeRunner{
|
|
loader: supplementOnlyCatalogLoader{},
|
|
transport: transport.NewClient(nil),
|
|
fallback: executor.EchoRunner{},
|
|
}
|
|
|
|
inv := executor.Invocation{
|
|
Kind: "api_invocation",
|
|
CanonicalProduct: "conference",
|
|
Tool: "nonexistent_tool",
|
|
CanonicalPath: "conference.nonexistent_tool",
|
|
Params: map[string]any{},
|
|
}
|
|
|
|
_, err := runner.Run(context.Background(), inv)
|
|
if err == nil {
|
|
t.Fatalf("expected endpoint_not_resolved error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "endpoint not resolved") {
|
|
t.Fatalf("expected endpoint_not_resolved error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "nonexistent_tool") {
|
|
t.Fatalf("error should name the missing tool; got %v", err)
|
|
}
|
|
}
|