Files
larksuite-cli/internal/qualitygate/rules/dryrun_test.go
2026-06-17 16:29:33 +08:00

675 lines
25 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package rules
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/skillscan"
)
func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
raw := "=== Dry Run ===\n{\n \"api\": [{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]\n}\n"
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
if err != nil {
t.Fatalf("extractDryRunJSON() error = %v", err)
}
if got.Method != "GET" {
t.Fatalf("method = %q", got.Method)
}
if apiCallCount != 1 {
t.Fatalf("apiCallCount = %d, want 1", apiCallCount)
}
}
func TestExtractDryRunJSONSkipsBannerWithBraces(t *testing.T) {
raw := "banner {not json}\n{\"api\":[{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]}\n"
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
if err != nil {
t.Fatalf("extractDryRunJSON() error = %v", err)
}
if got.Method != "GET" || apiCallCount != 1 {
t.Fatalf("got request=%#v apiCallCount=%d, want GET and count 1", got, apiCallCount)
}
}
func TestRunCommandDisablesRemoteMetadata(t *testing.T) {
result := runCommand(context.Background(), "env", nil)
if result.Err != nil {
t.Fatalf("runCommand(env) error = %v, stderr=%s", result.Err, result.Stderr)
}
stdout := string(result.Stdout)
if !strings.Contains(stdout, "LARKSUITE_CLI_REMOTE_META=off") {
t.Fatalf("dry-run child env missing remote meta off in %q", stdout)
}
}
func TestRunCommandRemovesTemporaryConfigDir(t *testing.T) {
result := runCommand(context.Background(), "sh", []string{"-c", "printf %s \"$LARKSUITE_CLI_CONFIG_DIR\""})
if result.Err != nil {
t.Fatalf("runCommand(sh) error = %v, stderr=%s", result.Err, result.Stderr)
}
configDir := string(result.Stdout)
if configDir == "" {
t.Fatalf("child did not print LARKSUITE_CLI_CONFIG_DIR")
}
if _, err := os.Stat(configDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("temporary config dir %q still exists, stat error=%v", configDir, err)
}
}
func TestExtractDryRunJSONReportsNonAPIRequest(t *testing.T) {
_, _, err := extractDryRunJSON([]byte(`{"ok":true,"event":"subscribe"}`))
if !errors.Is(err, errNoDryRunAPI) {
t.Fatalf("error = %v, want errNoDryRunAPI", err)
}
}
func TestExtractDryRunJSONReturnsAPICallCount(t *testing.T) {
raw := `{"api":[{"method":"GET","url":"/open-apis/one"},{"method":"POST","url":"/open-apis/two"}]}`
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
if err != nil {
t.Fatalf("extractDryRunJSON() error = %v", err)
}
if got.URL != "/open-apis/one" || apiCallCount != 2 {
t.Fatalf("got request=%#v apiCallCount=%d, want first request and count 2", got, apiCallCount)
}
}
func TestClassifyExampleSkipsFileAndInteractiveInputs(t *testing.T) {
cases := map[string]string{
"lark-cli auth login": "interactive",
"lark-cli config init": "local_state",
"lark-cli docs +fetch --doc @doc.json": "file_input",
"lark-cli im message send --content -": "stdin",
"lark-cli drive file delete --file-token abc --yes": "high_risk",
"lark-cli docs +fetch --doc abc | jq -r '.data.title'": "stdin",
}
for raw, want := range cases {
got := classifyExample(skillscan.Example{Raw: raw})
if got.SkipReason != want {
t.Fatalf("%s skip reason = %q, want %q", raw, got.SkipReason, want)
}
}
}
func TestClassifyExampleDoesNotTreatQuotedPipeAsStdin(t *testing.T) {
got := classifyExample(skillscan.Example{Raw: `lark-cli api GET /open-apis/test --jq '.data.items[] | select(.ok)'`})
if !got.Executable || got.SkipReason != "" {
t.Fatalf("quoted jq pipe should remain executable, got %#v", got)
}
}
func TestDryRunIdentitySkipAllowsExplicitUser(t *testing.T) {
cmd := manifest.Command{Path: "mail +send", Identities: []string{"user"}}
if got := dryRunIdentitySkip(cmd, "lark-cli mail +send --as user"); got != "" {
t.Fatalf("explicit user dry-run skip = %q, want executable", got)
}
if got := dryRunIdentitySkip(cmd, "lark-cli mail +send"); got != "requires_user_identity" {
t.Fatalf("implicit user-only dry-run skip = %q, want requires_user_identity", got)
}
}
func TestRunDryRunsMaterializesTypedPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "chat-id", TakesValue: true, Usage: "chat ID (oc_xxx)"},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli im +chat-messages-list --chat-id <chat_id>",
SourceFile: "skills/lark-im/references/messages.md",
Line: 12,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("placeholder example should be executable after materialization: %#v", facts)
}
if facts[0].Raw != ex.Raw {
t.Fatalf("fact raw = %q, want original %q", facts[0].Raw, ex.Raw)
}
wantArgs := []string{"im", "+chat-messages-list", "--chat-id", "oc_test123", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/docx/v1/documents/doccnxxxx"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "docs +fetch",
Runnable: true,
Flags: []manifest.Flag{
{Name: "doc", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli docs +fetch --doc doccnxxxx # inspect --params shape first`,
SourceFile: "skills/lark-doc/SKILL.md",
Line: 12,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("commented example should be executable: %#v", facts)
}
wantArgs := []string{"docs", "+fetch", "--doc", "doccnxxxx", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im messages list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli im messages list --params '{"chat_id":"<chat_id>","page_token":"<PAGE_TOKEN>"}'`,
SourceFile: "skills/lark-im/references/messages.md",
Line: 20,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"im", "messages", "list", "--params", `{"chat_id":"oc_test123","page_token":"page_test123"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsWarnsWhenStdoutTruncated(t *testing.T) {
stdout := `{"api":[` + strings.Repeat(`{"method":"GET","url":"/open-apis/test"},`, 7000)
cliBin, _ := fakeDryRunCLI(t, stdout)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "docs +fetch",
Runnable: true,
Flags: []manifest.Flag{{Name: "dry-run"}},
}}}
ex := skillscan.Example{Raw: "lark-cli docs +fetch", SourceFile: "skills/lark-doc/SKILL.md", Line: 10}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 1 || diags[0].Action != report.ActionWarning {
t.Fatalf("truncated stdout should warn, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "stdout_truncated" {
t.Fatalf("truncated stdout fact should be skipped, got %#v", facts)
}
}
func TestRunDryRunsRejectsNonTimeoutFailure(t *testing.T) {
cliBin := fakeFailingCLI(t)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "docs +fetch",
Runnable: true,
Flags: []manifest.Flag{{Name: "dry-run"}},
}}}
ex := skillscan.Example{Raw: "lark-cli docs +fetch", SourceFile: "skills/lark-doc/SKILL.md", Line: 10}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 1 || diags[0].Action != report.ActionReject {
t.Fatalf("non-timeout dry-run failure should reject, got %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("non-timeout dry-run failure should remain executable evidence, got %#v", facts)
}
}
func TestRunDryRunsKeepsNonJSONParamsPlaceholderSkipped(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "api",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli api GET /open-apis/im/v1/messages --params 'container_id=oc_xxx&page_token=<PAGE_TOKEN>'`,
SourceFile: "skills/lark-im/references/lark-im-chat-messages-list.md",
Line: 111,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("non-JSON --params placeholder should skip without diagnostics, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "placeholder" {
t.Fatalf("non-JSON --params placeholder should stay skipped: %#v", facts)
}
}
func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "chat-id", TakesValue: true, Usage: "chat ID (oc_xxx)"},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli im +chat-messages-list --chat-id=<chat_id>",
SourceFile: "skills/lark-im/references/messages.md",
Line: 24,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("inline placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"im", "+chat-messages-list", "--chat-id=oc_test123", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
Runnable: true,
Flags: []manifest.Flag{{Name: "chat-id", TakesValue: true}, {Name: "dry-run"}},
}}}
ex := skillscan.Example{
Raw: "lark-cli im +chat-messages-list --container-id <chat_id>",
SourceFile: "skills/lark-im/references/messages.md",
Line: 31,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("unknown flags should be left to reference rules, got dry-run diagnostics %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "invalid_reference" {
t.Fatalf("unknown flag example should skip dry-run as invalid_reference: %#v", facts)
}
}
func TestRunDryRunsKeepsGenericTypePlaceholderSkipped(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "contact users list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "user-id-type", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli contact users list --user-id-type <type>",
SourceFile: "skills/lark-contact/references/users.md",
Line: 44,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("generic <type> placeholder should skip without dry-run diagnostics, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "placeholder" {
t.Fatalf("generic <type> placeholder should stay skipped: %#v", facts)
}
}
func TestRunDryRunsKeepsAmbiguousAppLikePlaceholderSkipped(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "approval tasks get",
Runnable: true,
Flags: []manifest.Flag{
{Name: "code", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli approval tasks get --code <approval_code>",
SourceFile: "skills/lark-approval/references/tasks.md",
Line: 52,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("ambiguous app-like placeholder should skip without dry-run diagnostics, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "placeholder" {
t.Fatalf("ambiguous app-like placeholder should stay skipped: %#v", facts)
}
}
func TestRunDryRunsPreservesMarkupLiteralWhileMaterializingPlaceholder(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"PATCH","url":"/open-apis/docx/v1/documents/doc_test123","body":{"content":"<p>ok</p>"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "docs +update",
Runnable: true,
Flags: []manifest.Flag{
{Name: "doc-token", TakesValue: true},
{Name: "body", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli docs +update --doc-token <doc_token> --body '<p>ok</p>'`,
SourceFile: "skills/lark-doc/references/update.md",
Line: 58,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("markup literal should not prevent placeholder dry-run: %#v", facts)
}
wantArgs := []string{"docs", "+update", "--doc-token", "doc_test123", "--body", "<p>ok</p>", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsKeepsUnmaterializablePlaceholdersSkipped(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "apps +html-publish",
Runnable: true,
Flags: []manifest.Flag{
{Name: "app-id", TakesValue: true},
{Name: "path", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli apps +html-publish --app-id "$APP" --path ./dist`,
SourceFile: "skills/lark-apps/references/html-publish.md",
Line: 103,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("unmaterializable placeholder should skip without diagnostics, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "placeholder" {
t.Fatalf("unmaterializable placeholder should stay skipped: %#v", facts)
}
}
func TestRunDryRunsKeepsCommandTemplatePlaceholdersSkipped(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{Path: "approval", Runnable: true, Flags: []manifest.Flag{{Name: "dry-run"}}}}}
ex := skillscan.Example{
Raw: "lark-cli approval <resource> <method> [flags]",
SourceFile: "skills/lark-approval/SKILL.md",
Line: 42,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("command template placeholder should skip without diagnostics, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "placeholder" {
t.Fatalf("command template placeholder should stay skipped: %#v", facts)
}
}
func TestRunDryRunsKeepsUnparseablePlaceholderExamplesSkipped(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "docs +update",
Runnable: true,
Flags: []manifest.Flag{
{Name: "doc-token", TakesValue: true},
{Name: "body", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli docs +update --doc-token <doc_token> --body '{"content":`,
SourceFile: "skills/lark-doc/references/update.md",
Line: 77,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), "missing-cli", m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("unparseable placeholder should skip without diagnostics, got %#v", diags)
}
if len(facts) != 1 || facts[0].Executable || facts[0].SkipReason != "placeholder" {
t.Fatalf("unparseable placeholder should stay skipped: %#v", facts)
}
}
func TestDryRunRequestShapeMismatchRejects(t *testing.T) {
fact := facts.CommandExample{
Raw: "lark-cli api GET /open-apis/test --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "POST", URL: "/open-apis/test"},
DryRun: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/test"},
APICallCount: 1,
}
diags := validateDryRunShape(fact)
if len(diags) != 1 || diags[0].Rule != "example_dry_run_request_shape" {
t.Fatalf("expected request-shape reject, got %#v", diags)
}
}
func TestDryRunRequestShapeMismatchRejectsUnexpectedParamsOrBody(t *testing.T) {
for _, fact := range []facts.CommandExample{
{
Raw: "lark-cli svc items list --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items"},
DryRun: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items", Params: map[string]any{"unexpected": "1"}},
APICallCount: 1,
},
{
Raw: "lark-cli svc items get --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items/1"},
DryRun: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items/1", Body: jsonRaw(`{"unexpected":true}`)},
APICallCount: 1,
},
} {
diags := validateDryRunShape(fact)
if len(diags) != 1 || diags[0].Rule != "example_dry_run_request_shape" {
t.Fatalf("expected request-shape reject for %#v, got %#v", fact, diags)
}
}
}
func TestDryRunRequestShapeMismatchRejectsMultipleAPICalls(t *testing.T) {
fact := facts.CommandExample{
Raw: "lark-cli svc items get --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items/1"},
DryRun: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items/1"},
APICallCount: 2,
}
diags := validateDryRunShape(fact)
if len(diags) != 1 || diags[0].Rule != "example_dry_run_request_shape" {
t.Fatalf("expected multiple-api request-shape reject, got %#v", diags)
}
}
func TestDryRunRequestShapeMismatchRejectsMissingAPICall(t *testing.T) {
fact := facts.CommandExample{
Raw: "lark-cli svc items get --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items/1"},
APICallCount: 0,
}
diags := validateDryRunShape(fact)
if len(diags) != 1 || diags[0].Rule != "example_dry_run_request_shape" {
t.Fatalf("expected missing-api request-shape reject, got %#v", diags)
}
}
func TestDryRunRequestShapeMismatchRejectsQueryMismatch(t *testing.T) {
fact := facts.CommandExample{
Raw: "lark-cli svc items get --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items", Query: map[string][]string{"page_size": {"20"}}},
DryRun: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items", Query: map[string][]string{"page_size": {"200"}}},
APICallCount: 1,
}
diags := validateDryRunShape(fact)
if len(diags) != 1 || diags[0].Rule != "example_dry_run_request_shape" {
t.Fatalf("expected query request-shape reject, got %#v", diags)
}
}
func TestDryRunRequestShapeMismatchRejectsParamMismatch(t *testing.T) {
fact := facts.CommandExample{
Raw: "lark-cli svc items list --params '{\"page_size\":1}' --dry-run",
ExpectedRequest: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items", Params: map[string]any{"page_size": float64(1)}},
DryRun: &facts.DryRunRequest{Method: "GET", URL: "/open-apis/svc/v1/items", Params: map[string]any{"page_size": float64(20)}},
APICallCount: 1,
}
diags := validateDryRunShape(fact)
if len(diags) != 1 || diags[0].Rule != "example_dry_run_request_shape" {
t.Fatalf("expected request-shape reject, got %#v", diags)
}
}
func TestDryRunFailureWarnsOnTimeout(t *testing.T) {
ex := skillscan.Example{SourceFile: "skills/lark-doc/SKILL.md", Line: 10}
diag := dryRunFailureDiagnostic(ex, commandResult{ExitCode: 1, TimedOut: true})
if diag.Action != report.ActionWarning {
t.Fatalf("timeout should warn instead of reject, got %#v", diag)
}
}
func TestDryRunMalformedWarnsWhenStdoutTruncated(t *testing.T) {
ex := skillscan.Example{SourceFile: "skills/lark-doc/SKILL.md", Line: 10}
diag := dryRunMalformedDiagnostic(ex, context.Canceled, commandResult{StdoutTruncated: true})
if diag.Action != report.ActionWarning {
t.Fatalf("truncated stdout should warn instead of reject, got %#v", diag)
}
}
func jsonRaw(raw string) json.RawMessage {
return json.RawMessage(raw)
}
func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run --doc abc")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
var count int
for _, arg := range got {
if arg == "--dry-run" {
count++
}
}
if count != 1 {
t.Fatalf("--dry-run count = %d, want 1 in %#v", count, got)
}
}
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"docs", "+fetch", "--dry-run=false", "--doc", "abc", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgForcesDryRunWhenLastValueDisablesIt(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run --doc abc --dry-run=0")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"docs", "+fetch", "--dry-run", "--doc", "abc", "--dry-run=0", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestDryRunIdentitySkipRequiresExplicitBotForDualIdentity(t *testing.T) {
cmd := manifest.Command{Path: "mail +triage", Identities: []string{"user", "bot"}}
if got := dryRunIdentitySkip(cmd, "lark-cli mail +triage"); got != "identity_auto_requires_state" {
t.Fatalf("skip = %q, want identity_auto_requires_state", got)
}
if got := dryRunIdentitySkip(cmd, "lark-cli mail +triage --as bot"); got != "" {
t.Fatalf("skip with --as bot = %q, want empty", got)
}
}
func fakeDryRunCLI(t *testing.T, stdout string) (string, string) {
t.Helper()
dir := t.TempDir()
argsPath := filepath.Join(dir, "args.txt")
cliPath := filepath.Join(dir, "fake-cli.sh")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + shellSingleQuote(argsPath) + "\nprintf '%s\\n' " + shellSingleQuote(stdout) + "\n"
if err := os.WriteFile(cliPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake cli: %v", err)
}
return cliPath, argsPath
}
func fakeFailingCLI(t *testing.T) string {
t.Helper()
dir := t.TempDir()
cliPath := filepath.Join(dir, "fake-cli.sh")
script := "#!/bin/sh\necho 'validation failed' >&2\nexit 2\n"
if err := os.WriteFile(cliPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake cli: %v", err)
}
return cliPath
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(data)
}
func readArgs(t *testing.T, path string) []string {
t.Helper()
raw := strings.TrimSuffix(readFile(t, path), "\n")
if raw == "" {
return nil
}
return strings.Split(raw, "\n")
}