Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6220d7d8b | ||
|
|
81bf0d2a6b | ||
|
|
f3a95d34a3 |
@@ -6,6 +6,13 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable Chat command compatibility** — restores the hidden migration
|
||||
entries for `chat send`, `chat history`, and their `im` aliases that PR #860
|
||||
removed, preserving the v1.0.56 command surface while directing callers to
|
||||
the supported `chat message send/list` commands.
|
||||
|
||||
## [1.0.57-beta.1] - 2026-08-05
|
||||
|
||||
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||
|
||||
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"chat send",
|
||||
"chat history",
|
||||
"im send",
|
||||
"im history",
|
||||
} {
|
||||
command, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil {
|
||||
t.Fatalf("find %s: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(tc.args)
|
||||
err := command.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
|
||||
}
|
||||
}
|
||||
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||
if listDirect.Flags().Lookup(flag) == nil {
|
||||
|
||||
@@ -54,6 +54,14 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
|
||||
}
|
||||
}
|
||||
|
||||
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
|
||||
command := hintSubCmd(use, hint)
|
||||
// Legacy callers may still pass the old command's flags. Let the migration
|
||||
// command consume them so Cobra reaches RunE and returns the replacement path.
|
||||
command.DisableFlagParsing = true
|
||||
return command
|
||||
}
|
||||
|
||||
type nativeChatTargetReader struct{}
|
||||
|
||||
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
@@ -8166,5 +8174,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
// compatibility hints available through both chat and im.
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
|
||||
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
flag string
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{path: "send", flag: "--group"},
|
||||
{path: "history", flag: "--group"},
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
caller := &productExampleCaller{}
|
||||
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
|
||||
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
t.Fatalf("find chat %s: %v", tc.path, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
|
||||
if len(remaining) != 0 || command.Name() != tc.path {
|
||||
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
|
||||
if !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
err = root.ExecuteContext(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,12 +275,6 @@ CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,6 +66,8 @@ var (
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
if driveWorkerContextErr(runCtx) != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2836,60 +2838,6 @@ 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 分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2922,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
||||
|
||||
totalSize := int64(200)
|
||||
content := makeTestContent(int(totalSize))
|
||||
|
||||
origClient := driveRangeClient
|
||||
t.Cleanup(func() { driveRangeClient = origClient })
|
||||
|
||||
driveRangeClient = &http.Client{
|
||||
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
|
||||
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
|
||||
var checks atomic.Int32
|
||||
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
|
||||
checks.Add(1)
|
||||
return context.Canceled
|
||||
})
|
||||
var requests atomic.Int32
|
||||
testseam.Swap(t, &driveRangeClient, &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// 每次请求加小延迟,让总处理时间接近 deadline
|
||||
time.Sleep(50 * time.Microsecond)
|
||||
var start, end int64
|
||||
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
|
||||
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
|
||||
}
|
||||
if end >= int64(len(content)) {
|
||||
end = int64(len(content)) - 1
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusPartialContent,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
|
||||
}
|
||||
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
|
||||
return resp, nil
|
||||
requests.Add(1)
|
||||
return nil, errors.New("worker context guard did not stop the request")
|
||||
}),
|
||||
})
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
|
||||
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
|
||||
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
|
||||
t.Fatalf("downloadRangedParts context guard: %v", err)
|
||||
}
|
||||
|
||||
// 多次尝试以确保覆盖(goroutine 调度非确定性)
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
|
||||
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
|
||||
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
|
||||
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
|
||||
cancel()
|
||||
if checks.Load() != 1 {
|
||||
t.Fatalf("worker context checks = %d, want 1", checks.Load())
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("worker requests = %d, want 0", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"testing"
|
||||
"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/pkg/edition"
|
||||
)
|
||||
@@ -316,35 +315,6 @@ 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
|
||||
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
|
||||
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
|
||||
|
||||
@@ -730,6 +730,7 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
|
||||
new string
|
||||
}{
|
||||
{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: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
|
||||
{name: "format", old: oldParameter.Format, new: newParameter.Format},
|
||||
@@ -738,13 +739,6 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet
|
||||
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,
|
||||
// wire-visible policy decision taken with the pinned MCP metadata
|
||||
// retirement. Production no longer projects MCP-sourced types unless
|
||||
@@ -789,48 +783,6 @@ func enumNarrowed(oldValues, newValues []string) bool {
|
||||
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) {
|
||||
failures := checkCompatibility(historical, current)
|
||||
if len(failures) > 0 {
|
||||
|
||||
@@ -596,67 +596,6 @@ 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) {
|
||||
oldTool := toolSchema{
|
||||
Parameters: map[string]parameterSchema{
|
||||
|
||||
@@ -78,29 +78,6 @@ type releaseTestRepo struct {
|
||||
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 {
|
||||
t.Helper()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
@@ -108,7 +85,7 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
|
||||
base := releaseTempDir(t)
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "work")
|
||||
remote := filepath.Join(base, "remote.git")
|
||||
mustRun(t, base, "git", "init", "--bare", remote)
|
||||
|
||||
Reference in New Issue
Block a user