Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf8645475 | ||
|
|
2667acdc85 | ||
|
|
d3d14eaddb | ||
|
|
b3f493d8b8 | ||
|
|
d6fd48f168 |
@@ -275,6 +275,12 @@ CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验
|
|||||||
},
|
},
|
||||||
Parameters: []contract.ParamDecl{
|
Parameters: []contract.ParamDecl{
|
||||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||||
|
// 与 doc block insert 对齐:同级/容器定位都映射到 MCP referenceBlockId,
|
||||||
|
// 避免 Cobra 名推断成 refBlock/parentBlock 误导 Agent 绑参。
|
||||||
|
{Name: "ref-block", Property: "referenceBlockId"},
|
||||||
|
{Name: "where", Property: "where"},
|
||||||
|
{Name: "parent-block", Property: "referenceBlockId"},
|
||||||
|
{Name: "index", Property: "index", InterfaceType: "integer"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2836,6 +2836,60 @@ func TestCrossPlatformCoverageDriveDownloadCancelNoResume(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 覆盖率补全:drive download cancel + --part-size(可续传)分支。
|
||||||
|
func TestCrossPlatformCoverageDriveDownloadCancelWithResumeHint(t *testing.T) {
|
||||||
|
// partSize != "" && !noResume → 「已保存断点」提示;CI 上偶发漏盖会拖低 overall。
|
||||||
|
oldGet := httpGetFile
|
||||||
|
httpGetFile = func(_ context.Context, _ string, _ map[string]string, _ string) error {
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { httpGetFile = oldGet })
|
||||||
|
|
||||||
|
dest := filepath.Join(t.TempDir(), "cancel-resume.bin")
|
||||||
|
mcpResp := `{"resourceUrl":"https://fake.invalid/f.bin","fileSize":100}`
|
||||||
|
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||||
|
{text: mcpResp},
|
||||||
|
}}
|
||||||
|
|
||||||
|
err := executeDriveEdge(t, caller,
|
||||||
|
"download", "--node", "node-1", "--output", dest, "--part-size", "1MB")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("应返回 context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deprecated folder create 警告路径 + publish set 缺 node 校验,补 overall 缓冲。
|
||||||
|
func TestCrossPlatformCoverageDriveFolderCreateDeprecatedAndPublishSetValidate(t *testing.T) {
|
||||||
|
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}}
|
||||||
|
if err := executeDriveEdge(t, caller, "folder", "create", "--name", "tmp-folder"); err != nil {
|
||||||
|
t.Fatalf("deprecated folder create should still run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := executeDriveEdge(t, &scriptedToolCaller{}, "publish", "set")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("publish set without node should fail validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// download-version cancel + --part-size(可续传)提示,额外 overall 缓冲。
|
||||||
|
func TestCrossPlatformCoverageDriveDownloadVersionCancelWithResumeHint(t *testing.T) {
|
||||||
|
oldGet := httpGetFile
|
||||||
|
httpGetFile = func(_ context.Context, _ string, _ map[string]string, _ string) error {
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { httpGetFile = oldGet })
|
||||||
|
|
||||||
|
dest := filepath.Join(t.TempDir(), "ver-cancel-resume.bin")
|
||||||
|
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||||
|
{text: `{"downloadUrl":"https://fake.invalid/f.bin","fileSize":100}`},
|
||||||
|
}}
|
||||||
|
err := executeDriveEdge(t, caller,
|
||||||
|
"download-version", "--node", "node-1", "--version", "3", "--output", dest, "--part-size", "1MB")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("应返回 context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// 覆盖率补全:drive.go:652 download-version 命令 cancel + --no-resume else 分支
|
// 覆盖率补全:drive.go:652 download-version 命令 cancel + --no-resume else 分支
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
)
|
)
|
||||||
@@ -315,6 +316,35 @@ func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定位 flag 必须声明为 MCP referenceBlockId,不能退化成 Cobra 名推断的
|
||||||
|
// refBlock / parentBlock,否则 Schema 会误导 Agent 绑参。
|
||||||
|
func TestDocWhiteboardInsertDeclaresReferenceBlockProperties(t *testing.T) {
|
||||||
|
cmd, remaining, err := newDocWhiteboardCommand().Find([]string{"insert"})
|
||||||
|
if err != nil || len(remaining) != 0 {
|
||||||
|
t.Fatalf("find doc whiteboard insert: command=%v remaining=%v err=%v", cmd, remaining, err)
|
||||||
|
}
|
||||||
|
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("doc whiteboard insert must publish ContractFinal")
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"node": "nodeId",
|
||||||
|
"ref-block": "referenceBlockId",
|
||||||
|
"where": "where",
|
||||||
|
"parent-block": "referenceBlockId",
|
||||||
|
"index": "index",
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, p := range final.Parameters {
|
||||||
|
got[p.Name] = p.Property
|
||||||
|
}
|
||||||
|
for name, property := range want {
|
||||||
|
if got[name] != property {
|
||||||
|
t.Fatalf("ParamDecl %q Property = %q, want %q (all=%#v)", name, got[name], property, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
|
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
|
||||||
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
|
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
|
||||||
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
|
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
|
||||||
|
|||||||
@@ -730,7 +730,6 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
|
|||||||
new string
|
new string
|
||||||
}{
|
}{
|
||||||
{name: "type", old: oldParameter.Type, new: newParameter.Type},
|
{name: "type", old: oldParameter.Type, new: newParameter.Type},
|
||||||
{name: "property", old: oldParameter.Property, new: newParameter.Property},
|
|
||||||
{name: "default", old: oldParameter.Default, new: newParameter.Default},
|
{name: "default", old: oldParameter.Default, new: newParameter.Default},
|
||||||
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
|
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
|
||||||
{name: "format", old: oldParameter.Format, new: newParameter.Format},
|
{name: "format", old: oldParameter.Format, new: newParameter.Format},
|
||||||
@@ -739,6 +738,13 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
|
|||||||
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name))
|
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Property remaps are incompatible unless a reviewed correction replaces
|
||||||
|
// flag-name inference with the ParamDecl-declared MCP field. Arbitrary
|
||||||
|
// non-empty A→B remaps remain a contract break.
|
||||||
|
if oldParameter.Property != newParameter.Property &&
|
||||||
|
!compatibleReviewedPropertyCorrection(toolPath, name, oldParameter.Property, newParameter.Property) {
|
||||||
|
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed property", toolPath, name))
|
||||||
|
}
|
||||||
// Clearing interface_type is accepted as compatible: a deliberate,
|
// Clearing interface_type is accepted as compatible: a deliberate,
|
||||||
// wire-visible policy decision taken with the pinned MCP metadata
|
// wire-visible policy decision taken with the pinned MCP metadata
|
||||||
// retirement. Production no longer projects MCP-sourced types unless
|
// retirement. Production no longer projects MCP-sourced types unless
|
||||||
@@ -783,6 +789,48 @@ func enumNarrowed(oldValues, newValues []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reviewedPropertyCorrection is an exact allowlist entry for replacing a
|
||||||
|
// historical wire property (typically flag-name inference) with the MCP field
|
||||||
|
// declared on leaf ParamDecl.Property. Do not use this to bless arbitrary
|
||||||
|
// remaps: each entry must name tool path, parameter, old property, and new
|
||||||
|
// property exactly.
|
||||||
|
type reviewedPropertyCorrection struct {
|
||||||
|
toolPath string
|
||||||
|
param string
|
||||||
|
oldProperty string
|
||||||
|
newProperty string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep this list tiny and evidence-backed. Prefer ParamDecl declaration; only
|
||||||
|
// add an entry when schema-compat would otherwise freeze a wrong inferred
|
||||||
|
// property that agents bind against.
|
||||||
|
var reviewedPropertyCorrections = []reviewedPropertyCorrection{
|
||||||
|
{
|
||||||
|
toolPath: "doc/doc.whiteboard_insert",
|
||||||
|
param: "parent-block",
|
||||||
|
oldProperty: "parentBlock",
|
||||||
|
newProperty: "referenceBlockId",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolPath: "doc/doc.whiteboard_insert",
|
||||||
|
param: "ref-block",
|
||||||
|
oldProperty: "refBlock",
|
||||||
|
newProperty: "referenceBlockId",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func compatibleReviewedPropertyCorrection(toolPath, param, oldProperty, newProperty string) bool {
|
||||||
|
for _, entry := range reviewedPropertyCorrections {
|
||||||
|
if entry.toolPath == toolPath &&
|
||||||
|
entry.param == param &&
|
||||||
|
entry.oldProperty == oldProperty &&
|
||||||
|
entry.newProperty == newProperty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func mergeContracts(historical, current schemaContract) (schemaContract, []string) {
|
func mergeContracts(historical, current schemaContract) (schemaContract, []string) {
|
||||||
failures := checkCompatibility(historical, current)
|
failures := checkCompatibility(historical, current)
|
||||||
if len(failures) > 0 {
|
if len(failures) > 0 {
|
||||||
|
|||||||
@@ -596,6 +596,67 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageSchemaCompatReviewedPropertyCorrection(t *testing.T) {
|
||||||
|
if !compatibleReviewedPropertyCorrection(
|
||||||
|
"doc/doc.whiteboard_insert", "ref-block", "refBlock", "referenceBlockId",
|
||||||
|
) {
|
||||||
|
t.Fatal("reviewed whiteboard ref-block correction must be accepted")
|
||||||
|
}
|
||||||
|
if !compatibleReviewedPropertyCorrection(
|
||||||
|
"doc/doc.whiteboard_insert", "parent-block", "parentBlock", "referenceBlockId",
|
||||||
|
) {
|
||||||
|
t.Fatal("reviewed whiteboard parent-block correction must be accepted")
|
||||||
|
}
|
||||||
|
if compatibleReviewedPropertyCorrection(
|
||||||
|
"doc/doc.whiteboard_insert", "ref-block", "refBlock", "otherProperty",
|
||||||
|
) {
|
||||||
|
t.Fatal("non-reviewed new property must remain incompatible")
|
||||||
|
}
|
||||||
|
if compatibleReviewedPropertyCorrection(
|
||||||
|
"doc/doc.create", "title", "title", "subject",
|
||||||
|
) {
|
||||||
|
t.Fatal("unlisted tool/param remap must remain incompatible")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseline := schemaContract{
|
||||||
|
Products: map[string]productSchema{
|
||||||
|
"doc": {Tools: map[string]toolSchema{
|
||||||
|
"doc.whiteboard_insert": {
|
||||||
|
PrimaryCLIPath: "doc whiteboard insert",
|
||||||
|
InterfaceMode: "composite",
|
||||||
|
Availability: "available",
|
||||||
|
Effect: "write",
|
||||||
|
Risk: "medium",
|
||||||
|
Confirmation: "user_required",
|
||||||
|
Idempotency: "unknown",
|
||||||
|
Parameters: map[string]parameterSchema{
|
||||||
|
"ref-block": {Type: "string", Property: "refBlock"},
|
||||||
|
"parent-block": {Type: "string", Property: "parentBlock"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
corrected := cloneContract(baseline)
|
||||||
|
corrected.Products["doc"].Tools["doc.whiteboard_insert"].Parameters["ref-block"] = parameterSchema{
|
||||||
|
Type: "string", Property: "referenceBlockId",
|
||||||
|
}
|
||||||
|
corrected.Products["doc"].Tools["doc.whiteboard_insert"].Parameters["parent-block"] = parameterSchema{
|
||||||
|
Type: "string", Property: "referenceBlockId",
|
||||||
|
}
|
||||||
|
if failures := checkCompatibility(baseline, corrected); len(failures) != 0 {
|
||||||
|
t.Fatalf("reviewed property corrections should pass: %v", failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
broken := cloneContract(baseline)
|
||||||
|
broken.Products["doc"].Tools["doc.whiteboard_insert"].Parameters["ref-block"] = parameterSchema{
|
||||||
|
Type: "string", Property: "subject",
|
||||||
|
}
|
||||||
|
if failures := checkCompatibility(baseline, broken); !strings.Contains(strings.Join(failures, "\n"), "changed property") {
|
||||||
|
t.Fatalf("unreviewed property remap should fail: %v", failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) {
|
func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) {
|
||||||
oldTool := toolSchema{
|
oldTool := toolSchema{
|
||||||
Parameters: map[string]parameterSchema{
|
Parameters: map[string]parameterSchema{
|
||||||
|
|||||||
@@ -78,6 +78,29 @@ type releaseTestRepo struct {
|
|||||||
verify string
|
verify string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// releaseTempDir is like t.TempDir but retries RemoveAll. Bare git remotes can
|
||||||
|
// briefly leave remote.git/objects non-empty on Linux CI, and testing.T's
|
||||||
|
// TempDir cleanup then fails the test after assertions already passed.
|
||||||
|
func releaseTempDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp("", "dws-release-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MkdirTemp: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
var last error
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
last = os.RemoveAll(dir)
|
||||||
|
if last == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(i+1) * 25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Logf("releaseTempDir cleanup after retries: %v", last)
|
||||||
|
})
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
@@ -85,7 +108,7 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
|||||||
t.Fatalf("Abs(repo root) error = %v", err)
|
t.Fatalf("Abs(repo root) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := t.TempDir()
|
base := releaseTempDir(t)
|
||||||
root := filepath.Join(base, "work")
|
root := filepath.Join(base, "work")
|
||||||
remote := filepath.Join(base, "remote.git")
|
remote := filepath.Join(base, "remote.git")
|
||||||
mustRun(t, base, "git", "init", "--bare", remote)
|
mustRun(t, base, "git", "init", "--bare", remote)
|
||||||
|
|||||||
Reference in New Issue
Block a user