package helpers import ( "bytes" "context" "reflect" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/spf13/cobra" ) func newDevAppTestRoot(runner executor.Runner) *cobra.Command { root := &cobra.Command{ Use: "dws", DisableAutoGenTag: true, } root.PersistentFlags().Bool("dry-run", false, "dry run") root.PersistentFlags().Bool("yes", false, "yes") root.AddCommand(newDevAppCommand(runner)) return root } type devAppResponseRunner struct { last executor.Invocation response map[string]any } func (r *devAppResponseRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { r.last = invocation invocation.Implemented = true return executor.Result{Invocation: invocation, Response: r.response}, nil } func TestDevAppMemberCommandsBuildToolParams(t *testing.T) { cases := []struct { name string cmd string args []string wantTool string wantParams map[string]any }{ { name: "list", cmd: "list", args: []string{"--app-id", "app-001"}, wantTool: "list_open_dev_app_members", wantParams: map[string]any{ "unifiedAppId": "app-001", }, }, { name: "add multiple users", cmd: "add", args: []string{"--app-id", "app-001", "--users", "userId1,userId2,userId3,userId4", "--member-type", "DEVELOPER", "--yes"}, wantTool: "add_open_dev_app_members", wantParams: map[string]any{ "unifiedAppId": "app-001", "memberUserIds": []string{"userId1", "userId2", "userId3", "userId4"}, "memberType": "DEVELOPER", }, }, { name: "remove trims users", cmd: "remove", args: []string{"--app-id", "app-001", "--users", " userId1 , userId2 ", "--member-type", "DEVELOPER", "--yes"}, wantTool: "remove_open_dev_app_members", wantParams: map[string]any{ "unifiedAppId": "app-001", "memberUserIds": []string{"userId1", "userId2"}, "memberType": "DEVELOPER", }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp", "member", tc.cmd}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.CanonicalProduct; got != "devapp" { t.Fatalf("CanonicalProduct = %q, want devapp", got) } if got := runner.last.Tool; got != tc.wantTool { t.Fatalf("Tool = %q, want %q", got, tc.wantTool) } if !reflect.DeepEqual(runner.last.Params, tc.wantParams) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams) } }) } } func TestDevAppCommandHasAppAliasAndCoreCommands(t *testing.T) { root := newDevAppCommand(&captureRunner{}) if root.Name() != "devapp" { t.Fatalf("Name() = %q, want devapp", root.Name()) } hasAlias := false for _, alias := range root.Aliases { if alias == "app" { hasAlias = true } } if !hasAlias { t.Fatalf("Aliases = %v, want app", root.Aliases) } for _, name := range []string{"list", "get", "create", "update", "delete", "inactive", "active", "credentials", "webapp", "permission", "member", "security", "robot", "version"} { if _, _, err := root.Find([]string{name}); err != nil { t.Fatalf("missing command %q: %v", name, err) } } } func TestDevAppRobotCommandsBuildToolParams(t *testing.T) { cases := []struct { name string args []string wantTool string wantParams map[string]any }{ { name: "create sync", args: []string{"robot", "create", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--yes"}, wantTool: "create_dingtalk_robot", wantParams: map[string]any{ "appName": "智能体", "robotName": "小助手", "desc": "审批问答", }, }, { name: "submit async fills media placeholders", args: []string{"robot", "submit", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--task-id", "t-1", "--yes"}, wantTool: "submit_robot_create_task", wantParams: map[string]any{ "appName": "智能体", "robotName": "小助手", "desc": "审批问答", "robotMediaId": "", "previewMediaId": "", "taskId": "t-1", }, }, { name: "result", args: []string{"robot", "result", "--task-id", "t-1"}, wantTool: "query_robot_create_result", wantParams: map[string]any{"taskId": "t-1"}, }, { name: "get config", args: []string{"robot", "get", "--unified-app-id", "u-1"}, wantTool: "get_open_dev_app_robot_config", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, { name: "config create with skills and mode", args: []string{"robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--brief", "审批助手", "--mode", "2", "--skills", "qa,approval", "--add-scope", "--yes"}, wantTool: "create_open_dev_app_robot_config", wantParams: map[string]any{ "unifiedAppId": "u-1", "name": "小助手", "brief": "审批助手", "mode": 2, "skillList": []string{"qa", "approval"}, "isAddScope": true, }, }, { name: "offline", args: []string{"robot", "offline", "--unified-app-id", "u-1", "--yes"}, wantTool: "offline_open_dev_app_robot", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.CanonicalProduct; got != "devapp" { t.Fatalf("CanonicalProduct = %q, want devapp", got) } if got := runner.last.Tool; got != tc.wantTool { t.Fatalf("Tool = %q, want %q", got, tc.wantTool) } if !reflect.DeepEqual(runner.last.Params, tc.wantParams) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams) } }) } } func TestDevAppVersionCommandsBuildToolParams(t *testing.T) { cases := []struct { name string args []string wantTool string wantParams map[string]any }{ { name: "create", args: []string{"version", "create", "--unified-app-id", "u-1", "--version", "1.0.1", "--desc", "新增机器人", "--yes"}, wantTool: "create_open_dev_app_version", wantParams: map[string]any{ "unifiedAppId": "u-1", "version": "1.0.1", "description": "新增机器人", }, }, { name: "list", args: []string{"version", "list", "--unified-app-id", "u-1", "--page", "2", "--page-size", "5"}, wantTool: "list_open_dev_app_versions", wantParams: map[string]any{ "unifiedAppId": "u-1", "currentPage": 2, "pageSize": 5, }, }, { name: "get detail", args: []string{"version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"}, wantTool: "get_open_dev_app_version_detail", wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1"}, }, { name: "check-approval forces dryRun", args: []string{"version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"}, wantTool: "publish_open_dev_app_version", wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1", "dryRun": true}, }, { name: "publish sets dryRun false and sensitive", args: []string{"version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--confirm-sensitive", "--approver", "user-1", "--yes"}, wantTool: "publish_open_dev_app_version", wantParams: map[string]any{ "unifiedAppId": "u-1", "versionId": "v-1", "dryRun": false, "confirmedSensitive": true, "approverUserId": "user-1", }, }, { name: "status", args: []string{"version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"}, wantTool: "get_open_dev_app_version_status", wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != tc.wantTool { t.Fatalf("Tool = %q, want %q", got, tc.wantTool) } if !reflect.DeepEqual(runner.last.Params, tc.wantParams) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams) } }) } } func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) { cases := []struct { name string args []string }{ {name: "robot create", args: []string{"devapp", "robot", "create", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批"}}, {name: "robot config", args: []string{"devapp", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手"}}, {name: "robot offline", args: []string{"devapp", "robot", "offline", "--unified-app-id", "u-1"}}, {name: "version create", args: []string{"devapp", "version", "create", "--unified-app-id", "u-1", "--version", "1.0.1"}}, {name: "version publish", args: []string{"devapp", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(tc.args) err := root.Execute() if err == nil { t.Fatal("Execute() error = nil, want write guard") } if !strings.Contains(err.Error(), "write operation") { t.Fatalf("error = %q, want write guard", err.Error()) } if runner.last.Tool != "" { t.Fatalf("tool = %q, want no invocation", runner.last.Tool) } }) } } func TestDevAppListBuildsListByConditionParams(t *testing.T) { runner := &captureRunner{} root := newDevAppCommand(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"list", "--name", "Waker", "--page", "2", "--page-size", "5", "--sort", "gmt_modified", "--order", "desc"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != "list_open_dev_apps_by_condition" { t.Fatalf("Tool = %q, want list_open_dev_apps_by_condition", got) } want := map[string]any{ "currentPage": 2, "pageSize": 5, "appName": "Waker", "sortType": "gmt_modified", "sortOrder": "desc", } if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppGetBuildsDetailParams(t *testing.T) { runner := &captureRunner{} root := newDevAppCommand(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"get", "--unified-app-id", "u-1"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != "get_open_dev_app_detail" { t.Fatalf("Tool = %q, want get_open_dev_app_detail", got) } want := map[string]any{"unifiedAppId": "u-1"} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppCreateUsesCurrentInnerToolAndWriteGuard(t *testing.T) { runner := &captureRunner{} root := newDevAppCommand(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"create", "--name", "Demo"}) err := root.Execute() if err == nil || !strings.Contains(err.Error(), "--yes") { t.Fatalf("error = %v, want write guard", err) } runner = &captureRunner{} root = newDevAppTestRoot(runner) root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devapp", "create", "--name", "Demo", "--desc", "internal app", "--yes"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != "create_inner_app" { t.Fatalf("Tool = %q, want create_inner_app", got) } want := map[string]any{"appName": "Demo", "appDesc": "internal app"} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppUpdateUsesCurrentInnerTool(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devapp", "update", "--agent-id", "123", "--desc", "new desc", "--yes"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != "update_inner_app" { t.Fatalf("Tool = %q, want update_inner_app", got) } want := map[string]any{"agentId": 123, "appDesc": "new desc"} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppLifecycleBuildsLocatorParams(t *testing.T) { cases := []struct { name string args []string wantTool string wantParams map[string]any }{ { name: "delete by agent id", args: []string{"delete", "--agent-id", "123", "--yes"}, wantTool: "delete_inner_app", wantParams: map[string]any{"agentId": 123}, }, { name: "inactive by unified app id", args: []string{"inactive", "--unified-app-id", "u-1", "--yes"}, wantTool: "inactive_inner_app", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, { name: "active by app key", args: []string{"active", "--app-key", "dingxxx", "--yes"}, wantTool: "active_inner_app", wantParams: map[string]any{"appKey": "dingxxx"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != tc.wantTool { t.Fatalf("Tool = %q, want %q", got, tc.wantTool) } if !reflect.DeepEqual(runner.last.Params, tc.wantParams) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams) } }) } } func TestDevAppWebappCommandsBuildParams(t *testing.T) { cases := []struct { name string args []string wantTool string wantParams map[string]any }{ { name: "get", args: []string{"webapp", "get", "--unified-app-id", "u-1"}, wantTool: "get_webapp_config", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, { name: "config", args: []string{"webapp", "config", "--unified-app-id", "u-1", "--homepage-link", "https://example.com", "--pc-homepage-link", "https://pc.example.com", "--yes"}, wantTool: "set_webapp_config", wantParams: map[string]any{ "unifiedAppId": "u-1", "homepageLink": "https://example.com", "pcHomepageLink": "https://pc.example.com", }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != tc.wantTool { t.Fatalf("Tool = %q, want %q", got, tc.wantTool) } if !reflect.DeepEqual(runner.last.Params, tc.wantParams) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams) } }) } } func TestDevAppPermissionCommandsBuildParams(t *testing.T) { cases := []struct { name string args []string wantTool string wantParams map[string]any }{ { name: "list", args: []string{"permission", "list", "--agent-id", "123", "--keyword", "手机号", "--status", "all", "--limit", "5"}, wantTool: "list_open_dev_app_permissions", wantParams: map[string]any{ "agentId": 123, "keyword": "手机号", "authStatus": "ALL", "limit": 5, }, }, { name: "add", args: []string{"permission", "add", "--agent-id", "123", "--permissions", "Contact.User.mobile,qyapi_robot_sendmsg", "--yes"}, wantTool: "apply_open_dev_app_permissions", wantParams: map[string]any{ "agentId": 123, "scopeValues": []string{"Contact.User.mobile", "qyapi_robot_sendmsg"}, }, }, { name: "remove", args: []string{"permission", "remove", "--agent-id", "123", "--permission", "Contact.User.mobile", "--yes"}, wantTool: "remove_open_dev_app_permission", wantParams: map[string]any{ "agentId": 123, "scopeValue": "Contact.User.mobile", }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != tc.wantTool { t.Fatalf("Tool = %q, want %q", got, tc.wantTool) } if !reflect.DeepEqual(runner.last.Params, tc.wantParams) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, tc.wantParams) } }) } } func TestDevAppCredentialsGetBuildsParams(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devapp", "credentials", "get", "--agent-id", "123"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.Tool; got != "get_open_dev_app_credentials" { t.Fatalf("Tool = %q, want get_open_dev_app_credentials", got) } want := map[string]any{"agentId": 123} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppCredentialsGetKeepsSecretFields(t *testing.T) { runner := &devAppResponseRunner{ response: map[string]any{ "content": map[string]any{ "agentId": 123, "appKey": "dingxxx", "appSecret": "secret-app", "clientSecret": "secret-client", }, }, } root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devapp", "credentials", "get", "--agent-id", "123"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } rendered := out.String() for _, expected := range []string{"appSecret", "clientSecret", "secret-app", "secret-client"} { if !strings.Contains(rendered, expected) { t.Fatalf("credentials output missing %q:\n%s", expected, rendered) } } } func TestDevAppMemberCommandsValidateRequiredFlags(t *testing.T) { cases := []struct { name string cmd string args []string wantErr string }{ {name: "list requires app", cmd: "list", args: nil, wantErr: "--app-id is required"}, {name: "add requires users", cmd: "add", args: []string{"--app-id", "app-001", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--users is required"}, {name: "add rejects empty users", cmd: "add", args: []string{"--app-id", "app-001", "--users", " , ", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--users must contain at least one userId"}, {name: "remove requires member type", cmd: "remove", args: []string{"--app-id", "app-001", "--users", "userId1", "--dry-run"}, wantErr: "--member-type is required"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(append([]string{"devapp", "member", tc.cmd}, tc.args...)) err := root.Execute() if err == nil { t.Fatalf("Execute() error = nil, want %q", tc.wantErr) } if !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("error = %q, want to contain %q", err.Error(), tc.wantErr) } if runner.last.Tool != "" { t.Fatalf("tool = %q, want no invocation", runner.last.Tool) } }) } } func TestDevAppSecurityConfigBuildsOnlyProvidedLists(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{ "devapp", "security", "config", "--app-id", "app-001", "--ip-whitelist", "192.0.2.10,192.0.2.11", "--redirect-url", "https://callback.example.invalid/callback", "--sso-url", "https://sso.example.invalid/sso", "--dry-run", }) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } if got := runner.last.CanonicalProduct; got != "devapp" { t.Fatalf("CanonicalProduct = %q, want devapp", got) } if got := runner.last.Tool; got != "update_app_security_config" { t.Fatalf("Tool = %q, want update_app_security_config", got) } want := map[string]any{ "unifiedAppId": "app-001", "ipWhiteList": []string{"192.0.2.10", "192.0.2.11"}, "redirectUrls": []string{"https://callback.example.invalid/callback"}, "otherAuthUrls": []string{"https://sso.example.invalid/sso"}, } if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppSecurityConfigOmitsAbsentOptionalLists(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devapp", "security", "config", "--app-id", "app-001", "--redirect-url", "https://callback.example.invalid/callback", "--dry-run"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } want := map[string]any{ "unifiedAppId": "app-001", "redirectUrls": []string{"https://callback.example.invalid/callback"}, } if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } } func TestDevAppSecurityConfigRequiresAtLeastOneConfig(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devapp", "security", "config", "--app-id", "app-001", "--dry-run"}) err := root.Execute() if err == nil { t.Fatal("Execute() error = nil, want validation error") } if !strings.Contains(err.Error(), "one of --ip-whitelist, --redirect-url, or --sso-url is required") { t.Fatalf("error = %q", err.Error()) } if runner.last.Tool != "" { t.Fatalf("tool = %q, want no invocation", runner.last.Tool) } } func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) { cases := []struct { name string args []string }{ { name: "member add", args: []string{"devapp", "member", "add", "--app-id", "app-001", "--users", "userId1", "--member-type", "DEVELOPER"}, }, { name: "member remove", args: []string{"devapp", "member", "remove", "--app-id", "app-001", "--users", "userId1", "--member-type", "DEVELOPER"}, }, { name: "security config", args: []string{"devapp", "security", "config", "--app-id", "app-001", "--redirect-url", "https://callback.example.invalid/callback"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { runner := &captureRunner{} root := newDevAppTestRoot(runner) var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs(tc.args) err := root.Execute() if err == nil { t.Fatal("Execute() error = nil, want write guard") } if !strings.Contains(err.Error(), "write operation") { t.Fatalf("error = %q, want write guard", err.Error()) } if runner.last.Tool != "" { t.Fatalf("tool = %q, want no invocation", runner.last.Tool) } }) } }