Compare commits

..

2 Commits

Author SHA1 Message Date
shanglei
041bf48e0e feat: unify output surface contract for flags, formats and errors
Consolidate the CLI output/error contract so agents can predict behavior
from a command's declaration:

- --json / --format resolved through a single FormatCapabilities set;
  explicit --format wins over --json across api, service, shortcut and
  auth scopes
- --format pretty renders as indented JSON via its own Format case,
  no longer aliased to table
- a single root FlagErrorFunc classifies pflag typed errors; the mail
  and sheets local FlagErrorFunc hooks are removed
- cobra argument, required-flag and flag-group errors are typed at their
  validation stage; the usage-error text markers are removed
- root and group unknown commands return structured suggestions
- manifest-export marks pure group nodes as non-runnable
2026-07-14 19:04:01 +08:00
liangshuo-1
37d490a198 fix: unify dry-run output contract (#1870)
* fix: unify dry-run output contract

* fix: address dry-run review feedback

* fix(dryrun): tighten preview contract and unify data shape

- transcribe HTTP method verbatim in previews (HEAD/OPTIONS were
  reported as GET); reject an empty method in api with a typed error
- unify the dry-run data payload across api/service/shortcut paths:
  {api, context?: {app_id, user_open_id}}; drop data.as — the envelope
  top-level identity is the single identity source
- mark pretty dry-run stdout with '# dry-run: request not sent' so logs
  that drop stderr still show it was a preview
- extract the shared preview builder, collapse PrintDryRunWithFile's
  loose params into FileUploadMeta, and fail loudly on nil previews
- revert description-marker identity parsing: stale prose must not
  override corrected accessTokens (blocks legal user calls on
  images.create); identity gating keys off accessTokens only
- pin the new contracts with tests: verbatim method, three-way context
  parity, nil-preview error, empty-context omission, marker line

* docs(agents): add typed-data, faithful-transcription, and contract-test conventions

- typed struct at the boundary over map[string]interface{} threading;
  distinct types where values could swap silently (internal/meta.Token)
- transcribe input verbatim in previews/transformations; reject
  unhonorable flag combinations with typed errors instead of silently
  substituting behavior
- contract tests must fail when the implementation is reverted

* test: migrate dry-run tests grown on main to the envelope format

main gained raw-format dry-run readers while the PR was in flight
(wiki drive export #1802, drive list comments #1845, slash commands,
sheets history, docs fetch, mail draft-send/triage, vc meeting events).
Migrate them to the envelope accessors (clie2e.DryRunGet / data-wrapped
decoders) and drop the now-redundant DryRunData extractions in files
unified on DryRunGet.

---------

Co-authored-by: guokexin.02 <264159873+Tantanz20020918@users.noreply.github.com>
2026-07-14 10:54:16 +08:00
129 changed files with 2734 additions and 2237 deletions

View File

@@ -105,6 +105,20 @@ Signatures that are easy to guess wrong:
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
### Typed data over loose maps
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
### Transcribe faithfully — no silent fallbacks
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
### Use `vfs.*` instead of `os.*`
All filesystem access goes through `internal/vfs`. This enables test mocking.
@@ -116,6 +130,7 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
### Tests
- Every behavior change needs a test alongside the change.
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.

View File

@@ -40,6 +40,7 @@ type APIOptions struct {
PageLimit int
PageDelay int
Format string
JSON bool
JqExpr string
DryRun bool
File string
@@ -88,6 +89,11 @@ Examples:
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -103,8 +109,8 @@ Examples:
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -116,7 +122,7 @@ Examples:
return nil, cobra.ShellCompDirectiveNoFileComp
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "write")
@@ -130,6 +136,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
if opts.Method == "" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"HTTP method must not be empty").
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
WithParam("<method>")
}
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
@@ -243,9 +256,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
}
return apiDryRun(f, request, config, opts.Format)
return apiDryRun(f, request, config, opts)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -256,10 +269,7 @@ func apiRun(opts *APIOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
format, _ := output.ParseFormat(opts.Format)
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
@@ -297,8 +307,19 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
@@ -325,6 +346,24 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
}
switch format {
case output.FormatPretty:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatPretty)
return errs.MarkRaw(apiErr)
}
scanResult := output.ScanForSafety(commandPath, result, errOut)
if scanResult.Blocked {
return errs.MarkRaw(scanResult.BlockErr)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
output.FormatValue(out, result, output.FormatPretty)
return nil
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {

View File

@@ -68,8 +68,44 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
}
func TestApiCmd_OutputFormatResolution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "json shorthand", args: []string{"--json"}, want: "json"},
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
args := []string{"GET", "/open-apis/test", "--as", "bot"}
cmd.SetArgs(append(args, tt.args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected options to be captured")
}
if gotOpts.Format != tt.want {
t.Fatalf("format = %q, want %q", gotOpts.Format, tt.want)
}
})
}
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -79,12 +115,42 @@ func TestApiCmd_DryRun(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected dry run output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", got["data"])
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
if strings.Contains(stdout.String(), "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
}
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
}
}
@@ -139,6 +205,43 @@ func TestApiCmd_BotMode(t *testing.T) {
}
}
func TestApiCmd_PrettyFormatsRealResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
}
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -152,6 +255,22 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected validation error for empty HTTP method")
}
if !strings.Contains(err.Error(), "method") {
t.Fatalf("error should name the method argument, got: %v", err)
}
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -525,6 +644,46 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
}
}
func TestApiCmd_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("page-all pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
}
}
type apiContentSafetyProvider struct {
called bool
path string
@@ -1000,11 +1159,23 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
}
}

View File

@@ -355,7 +355,31 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
func TestAuthScopesCmd_JSONShorthand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected options to be captured")
}
if !gotOpts.JSON || gotOpts.Format != "json" {
t.Fatalf("JSON = %v, format = %q; want true, json", gotOpts.JSON, gotOpts.Format)
}
}
func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -376,8 +400,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
if gotOpts.Format != "pretty" {
t.Errorf("expected explicit format pretty, got %s", gotOpts.Format)
}
}

View File

@@ -31,9 +31,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -41,8 +43,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.JSONPrettyFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return output.JSONPrettyFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -75,10 +80,10 @@ func authScopesRun(opts *ScopesOptions) error {
"failed to get app scope info: %v", err).WithCause(err)
}
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
fmt.Fprintf(f.IOStreams.Out, " • %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{

View File

@@ -4,9 +4,11 @@
package auth
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
@@ -26,6 +28,35 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
t.Cleanup(func() { getAppInfoFn = prev })
}
func TestAuthScopesRun_PrettyWritesBulletedScopesToStdout(t *testing.T) {
prev := getAppInfoFn
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
return &appInfo{UserScopes: []string{"im:message"}}, nil
}
t.Cleanup(func() { getAppInfoFn = prev })
opts := scopesTestFactory(t)
opts.Format = "pretty"
out, ok := opts.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("stdout type = %T, want *bytes.Buffer", opts.Factory.IOStreams.Out)
}
errOut, ok := opts.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("stderr type = %T, want *bytes.Buffer", opts.Factory.IOStreams.ErrOut)
}
if err := authScopesRun(opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(out.String(), " • im:message\n") {
t.Fatalf("stdout missing bulleted scope: %q", out.String())
}
if strings.Contains(errOut.String(), "im:message") {
t.Fatalf("scope should remain on stdout, stderr = %q", errOut.String())
}
}
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
// authScopesRun. Config has a non-empty AppID so we get past the config gate
// and reach the getAppInfoFn call.

View File

@@ -234,6 +234,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
installCobraValidationGuards(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)

View File

@@ -4,11 +4,15 @@
package cmd
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -26,6 +30,85 @@ func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
}
}
func buildValidationTestRoot(t *testing.T) *cobra.Command {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return Build(context.Background(), cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}),
WithoutPlugins(),
WithoutServiceCommands(),
WithoutStrictMode(),
)
}
func TestBuiltRoot_TopLevelTypoReturnsStructuredSuggestion(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{"imm"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "imm" {
t.Fatalf("params = %v, want one entry named imm", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "im" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want im", validationErr.Params[0].Suggestions)
}
}
func TestBuiltRoot_SheetsOneRequiredGroupReturnsValidationExit(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{
"sheets", "+csv-put",
"--url", "https://example.com/sheets/token",
"--sheet-name", "Sheet1",
"--csv", "a,b",
})
err := root.Execute()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestBuiltRoot_MailUnknownFlagUsesSharedSuggestions(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{"mail", "+send", "--tos", "alice@example.com"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "--tos" {
t.Fatalf("params = %v, want one entry named --tos", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "--to" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want --to", validationErr.Params[0].Suggestions)
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root

View File

@@ -9,28 +9,18 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
func parseFlagError(t *testing.T, c *cobra.Command, args ...string) error {
t.Helper()
err := c.Flags().Parse(args)
if err == nil {
t.Fatalf("Parse(%v) returned nil", args)
}
return err
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
@@ -39,7 +29,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
@@ -82,23 +72,86 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
c.Flags().String("find", "", "")
err := flagDidYouMean(c, parseFlagError(t, c, "--find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
// Non-unknown-flag errors retain the same validation shape and identify the
// flag from pflag's typed ValueRequiredError.
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
if len(verr.Params) != 1 || verr.Params[0].Name != "--find" {
t.Errorf("Params=%v, want one entry named --find", verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_SheetsListsVisibleFlags(t *testing.T) {
root := &cobra.Command{Use: "root"}
sheets := &cobra.Command{Use: "sheets"}
cmdmeta.SetDomain(sheets, "sheets")
root.AddCommand(sheets)
sheets.Flags().String("range", "", "")
sheets.Flags().Int("width", 0, "")
err := flagDidYouMean(sheets, parseFlagError(t, sheets, "--cols"))
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(validationErr.Hint, want) {
t.Errorf("hint should include %q, got %q", want, validationErr.Hint)
}
}
}
func TestFlagDidYouMean_InvalidValueTypedError(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().Int("width", 0, "")
// A non-numeric value for a typed flag surfaces pflag's InvalidValueError.
err := flagDidYouMean(c, parseFlagError(t, c, "--width=abc"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--width" || verr.Params[0].Reason != "invalid flag value" {
t.Errorf("Params = %v, want one --width entry with reason 'invalid flag value'", verr.Params)
}
}
func TestFlagDidYouMean_InvalidSyntaxTypedError(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
// An empty flag name is bad flag syntax and surfaces pflag's InvalidSyntaxError.
err := flagDidYouMean(c, parseFlagError(t, c, "--=oops"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Reason != "invalid flag syntax" {
t.Errorf("Params = %v, want one entry with reason 'invalid flag syntax'", verr.Params)
}
}

View File

@@ -241,10 +241,10 @@ func configureFlagCompletions(args []string) {
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
// 3. Any untyped error that reaches this boundary is an internal fault.
// Cobra argument, required-flag and flag-group errors are typed at their
// execution stages by installCobraValidationGuards; flag parse errors are
// typed by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
@@ -283,57 +283,14 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
// Every user-input stage is typed before execution. A bare error here has
// crossed that boundary unexpectedly and must remain visible as an internal
// fault instead of being guessed from English message fragments.
fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
}
return false
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
// group commands (no Run/RunE) with an unknown_subcommand error.
//
@@ -345,6 +302,10 @@ func isCobraUsageError(err error) bool {
// with reason_code=risk_not_annotated.
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
// Cobra's legacy Args fallback rejects an unknown top-level token before
// RunE can produce ranked suggestions. Explicitly accepting positional
// tokens lets every pure group, including root, reach the shared guard.
cmd.Args = cobra.ArbitraryArgs
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
@@ -362,6 +323,132 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// installCobraValidationGuards types errors at the stage where Cobra knows
// they are user input: positional argument validation, required flags and flag
// groups. This removes the need for final-boundary message matching.
func installCobraValidationGuards(cmd *cobra.Command) {
if cmd == nil {
return
}
if validateArgs := cmd.Args; validateArgs != nil {
cmd.Args = func(c *cobra.Command, args []string) error {
err := validateArgs(c, args)
if err == nil || errs.IsTyped(err) {
return err
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
}
}
previousPreRunE := cmd.PreRunE
previousPreRun := cmd.PreRun
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if previousPreRunE != nil {
if err := previousPreRunE(c, args); err != nil {
return err
}
} else if previousPreRun != nil {
previousPreRun(c, args)
}
if err := c.ValidateRequiredFlags(); err != nil {
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
c.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 {
return
}
validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"})
})
return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath())
}
if err := c.ValidateFlagGroups(); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).
WithParams(invalidFlagGroupParams(err.Error())...).
WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()).
WithCause(err)
}
return nil
}
cmd.PreRun = nil
for _, child := range cmd.Commands() {
installCobraValidationGuards(child)
}
}
type flagGroupConstraint int
const (
flagGroupRequiredTogether flagGroupConstraint = iota
flagGroupOneRequired
flagGroupMutuallyExclusive
)
// invalidFlagGroupParams extracts the offending flag group from Cobra's
// flag-group validation error. The message always names the group as
// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are
// read straight from the message instead of re-deriving Cobra's private group
// state. Pinned to cobra v1.10.2's message format (see go.mod).
func invalidFlagGroupParams(message string) []errs.InvalidParam {
names := flagGroupNamesFromMessage(message)
if len(names) == 0 {
return nil
}
return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message))
}
func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam {
flagNames := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimLeft(name, "-")
if name != "" {
flagNames = append(flagNames, "--"+name)
}
}
if len(flagNames) == 0 {
return nil
}
group := "[" + strings.Join(flagNames, " ") + "]"
reason := "invalid flag combination in " + group
switch constraint {
case flagGroupRequiredTogether:
reason = "all of " + group + " required together"
case flagGroupOneRequired:
reason = "one of " + group + " required"
case flagGroupMutuallyExclusive:
reason = "only one of " + group + " allowed"
}
params := make([]errs.InvalidParam, 0, len(flagNames))
for _, name := range flagNames {
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
return params
}
func flagGroupNamesFromMessage(message string) []string {
start := strings.IndexByte(message, '[')
if start < 0 {
return nil
}
end := strings.IndexByte(message[start+1:], ']')
if end < 0 {
return nil
}
return strings.Fields(message[start+1 : start+1+end])
}
func flagGroupConstraintFromMessage(message string) flagGroupConstraint {
switch {
case strings.Contains(message, "at least one of the flags"):
return flagGroupOneRequired
case strings.Contains(message, "must all be set"):
return flagGroupRequiredTogether
case strings.Contains(message, "none of the others can be"):
return flagGroupMutuallyExclusive
default:
return flagGroupConstraint(-1)
}
}
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
@@ -592,17 +679,21 @@ func isLarkDomain(c *cobra.Command) bool {
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
// classifies pflag's typed parse errors and emits one stable validation shape.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
if ferr == nil {
return nil
}
var notExist *pflag.NotExistError
if !errors.As(ferr, &notExist) {
return typedFlagParseError(c, ferr)
}
name := notExist.GetSpecifiedName()
rawName := "--" + name
if notExist.GetSpecifiedShortnames() != "" {
rawName = "-" + name
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
@@ -614,36 +705,69 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
if cmdmeta.Domain(c) == "sheets" {
if list := inlineVisibleFlags(valid); list != "" {
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list)
} else {
hint = "valid flags: " + list
}
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
"unknown flag %q for %q", rawName, c.CommandPath()).
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint).
WithCause(ferr)
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
func typedFlagParseError(c *cobra.Command, ferr error) error {
param := ""
reason := "flag parse error"
var valueRequired *pflag.ValueRequiredError
var invalidValue *pflag.InvalidValueError
var invalidSyntax *pflag.InvalidSyntaxError
switch {
case errors.As(ferr, &valueRequired):
param = "--" + valueRequired.GetSpecifiedName()
if valueRequired.GetSpecifiedShortnames() != "" {
param = "-" + valueRequired.GetSpecifiedName()
}
reason = "flag value is required"
case errors.As(ferr, &invalidValue):
if invalidValue.GetFlag() != nil {
param = "--" + invalidValue.GetFlag().Name
}
reason = "invalid flag value"
case errors.As(ferr, &invalidSyntax):
param = invalidSyntax.GetSpecifiedFlag()
reason = "invalid flag syntax"
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath()).
WithCause(ferr)
if param != "" {
validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason})
}
return rest, true
return validationErr
}
func inlineVisibleFlags(names []string) string {
const limit = 25
if len(names) == 0 {
return ""
}
shown := names
suffix := ""
if len(shown) > limit {
shown = shown[:limit]
suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit)
}
flags := make([]string, len(shown))
for i, name := range shown {
flags[i] = "--" + name
}
return strings.Join(flags, ", ") + suffix
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and

View File

@@ -6,6 +6,7 @@ package cmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
@@ -284,9 +285,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values").
WithParam("--values")
exit := handleRootError(f, err)
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -381,10 +382,9 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are
// typed at the Cobra validation stage, before the final dispatcher.
func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -393,7 +393,15 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("values", "", "")
cmd.MarkFlagRequired("values")
installCobraValidationGuards(cmd)
err := cmd.Execute()
if err == nil {
t.Fatal("expected missing required flag error")
}
exit := handleRootError(f, err)
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -411,6 +419,93 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
}
}
func TestCobraValidationGuardTypesFlagGroupErrorsWithParams(t *testing.T) {
tests := []struct {
name string
mark func(*cobra.Command)
args []string
wantNames []string
wantReason string
}{
{
name: "one required",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsOneRequired("start-cell", "range")
},
wantNames: []string{"--start-cell", "--range"},
wantReason: "one of [--start-cell --range] required",
},
{
name: "required together",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsRequiredTogether("start-cell", "range")
},
args: []string{"--start-cell", "A1"},
wantNames: []string{"--start-cell", "--range"},
wantReason: "all of [--start-cell --range] required together",
},
{
name: "mutually exclusive",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
},
args: []string{"--start-cell", "A1", "--range", "B2"},
wantNames: []string{"--start-cell", "--range"},
wantReason: "only one of [--start-cell --range] allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("start-cell", "", "")
cmd.Flags().String("range", "", "")
tt.mark(cmd)
installCobraValidationGuards(cmd)
cmd.SetArgs(tt.args)
err := cmd.Execute()
if err == nil {
t.Fatal("expected flag group error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if len(validationErr.Params) != len(tt.wantNames) {
t.Fatalf("params = %v, want %d entries", validationErr.Params, len(tt.wantNames))
}
for i, wantName := range tt.wantNames {
if validationErr.Params[i].Name != wantName || validationErr.Params[i].Reason != tt.wantReason {
t.Errorf("params[%d] = %+v, want name=%q reason=%q", i, validationErr.Params[i], wantName, tt.wantReason)
}
}
})
}
}
func TestInvalidFlagGroupParamsFromMessage(t *testing.T) {
got := invalidFlagGroupParams("at least one of the flags in the group [start-cell range] is required")
if len(got) != 2 {
t.Fatalf("params = %v, want two entries", got)
}
for i, want := range []string{"--start-cell", "--range"} {
if got[i].Name != want || got[i].Reason != "one of [--start-cell --range] required" {
t.Errorf("params[%d] = %+v", i, got[i])
}
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),

View File

@@ -140,6 +140,7 @@ type ServiceMethodOptions struct {
PageLimit int
PageDelay int
Format string
JSON bool
JqExpr string
DryRun bool
File string // --file flag value
@@ -268,6 +269,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -299,8 +305,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if spec.risk == cmdutil.RiskHighRiskWrite {
@@ -311,7 +317,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
// Registered last so the collision guard sees the standard flags above.
@@ -403,9 +409,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
}
return serviceDryRun(f, request, config, opts.Format)
return serviceDryRun(f, request, config, opts)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -420,10 +426,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
format, _ := output.ParseFormat(opts.Format)
// Scope-insufficient (99991679) and all other Lark API codes route through
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
@@ -667,8 +670,19 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
@@ -695,6 +709,24 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
switch format {
case output.FormatPretty:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatPretty)
return apiErr
}
scanResult := output.ScanForSafety(commandPath, result, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
output.FormatValue(out, result, output.FormatPretty)
return nil
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {

View File

@@ -201,6 +201,42 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
}
}
func TestServiceMethod_OutputFormatResolution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "json shorthand", args: []string{"--json"}, want: "json"},
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
args := []string{"--as", "bot"}
cmd.SetArgs(append(args, tt.args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if captured == nil {
t.Fatal("expected options to be captured")
}
if captured.Format != tt.want {
t.Fatalf("format = %q, want %q", captured.Format, tt.want)
}
})
}
}
// ── dry-run / buildServiceRequest ──
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
@@ -224,13 +260,39 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), tt.wantInURL) {
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data := got["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != tt.wantInURL {
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
}
})
}
}
func TestServiceMethod_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
cmd.SetArgs([]string{
"--params", `{"file_token":"boxcn123abc"}`,
"--dry-run",
"--jq", ".data.api[0].url",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
t.Fatalf("jq output = %q, want %q", got, want)
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -318,8 +380,12 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
if err != nil {
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
}
if !strings.Contains(stdout.String(), "Dry Run") {
t.Error("expected dry-run output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
}
}
@@ -432,6 +498,43 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
}
}
func TestServiceMethod_PrettyFormatsRealResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
}
}
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
@@ -473,6 +576,48 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
}
}
func TestServiceMethod_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("page-all pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
}
}
type serviceContentSafetyProvider struct {
called bool
path string
@@ -765,26 +910,23 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
}
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
if validationErr.Param != "--format" {
t.Errorf("param = %q, want --format", validationErr.Param)
}
}
@@ -1081,11 +1223,23 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
}
}

View File

@@ -45,11 +45,38 @@ func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
if files.RunE == nil {
t.Error("files should have RunE installed")
}
if root.Args == nil {
t.Error("root should explicitly accept positional tokens so unknown commands reach RunE")
}
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
}
}
func TestTopLevelUnknownCommandReturnsStructuredSuggestion(t *testing.T) {
root, _, _ := newGroupTree()
installUnknownSubcommandGuard(root)
root.SetArgs([]string{"driv"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "driv" {
t.Fatalf("params = %v, want one entry named driv", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "drive" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want drive", validationErr.Params[0].Suggestions)
}
}
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
called := false

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

@@ -8,15 +8,29 @@ import (
"fmt"
"io"
"net/url"
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
type DryRunOutputOptions struct {
Format string
JqExpr string
CommandPath string
Identity core.Identity
Out io.Writer
ErrOut io.Writer
}
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
@@ -26,12 +40,21 @@ type DryRunAPICall struct {
Body interface{} `json:"body,omitempty"`
}
// DryRunContext is the execution context shared by every dry-run preview:
// which app would make the call and, when known, as which user. The identity
// itself lives at the envelope top level, not here.
type DryRunContext struct {
AppID string `json:"app_id,omitempty"`
UserOpenID string `json:"user_open_id,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
extra map[string]interface{}
desc string
calls []DryRunAPICall
context *DryRunContext
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
@@ -40,30 +63,22 @@ func NewDryRunAPI() *DryRunAPI {
// --- HTTP method builders (add a call, return self for chaining) ---
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
// call appends a request with the method transcribed verbatim, so previews
// never misreport what the real client would send.
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
@@ -98,12 +113,26 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
return d
}
// Context records the calling app/user under data.context; empty values are
// omitted, and a fully empty context is not emitted at all.
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
if appID == "" && userOpenID == "" {
return d
}
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
name := token[1:]
value, ok := d.extra[name]
if !ok {
return token
}
return url.PathEscape(fmt.Sprintf("%v", value))
})
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
@@ -118,13 +147,17 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+2)
m := make(map[string]interface{}, len(d.extra)+3)
for k, v := range d.extra {
m[k] = v
}
// Typed fields win over same-named extra keys.
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
for k, v := range d.extra {
m[k] = v
if d.context != nil {
m["context"] = d.context
}
return json.Marshal(m)
}
@@ -154,11 +187,7 @@ func (d *DryRunAPI) Format() string {
u += "?" + encodeParams(c.Params)
}
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteString(c.Method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
@@ -215,83 +244,74 @@ func encodeParams(params map[string]interface{}) string {
return vals.Encode()
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
// query params, and the app/user context common to every dry-run.
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
dr := NewDryRunAPI().call(request.Method, request.URL)
if len(request.Params) > 0 {
dr.Params(request.Params)
}
filePathDisplay := filePath
// Identity is reported at the envelope top level, not duplicated here.
dr.Context(config.AppID, config.UserOpenId)
return dr
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
dr := buildDryRunPreview(request, config)
filePathDisplay := file.FilePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": fileField, "path": filePathDisplay},
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
}
if formFields != nil {
fileInfo["form_fields"] = formFields
if file.FormFields != nil {
fileInfo["form_fields"] = file.FormFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
return WriteDryRun(dr, opts)
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
dr := buildDryRunPreview(request, config)
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
return WriteDryRun(dr, opts)
}
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
// Identity may be empty; the envelope omits it rather than guessing.
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
if dr == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
}
// The JqExpr guard is defensive: every entry point already rejects --jq
// combined with --format pretty via output.ValidateJqFlags.
if opts.Format == "pretty" && opts.JqExpr == "" {
// A nil ErrOut only skips the banner decoration (mirroring
// WriteSuccessEnvelope's warning path); the payload write to Out
// must fail loudly rather than be silently discarded.
if opts.ErrOut != nil {
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
}
// stdout carries its own marker so logs that drop stderr still show
// this was a preview, not an executed request.
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
fmt.Fprint(opts.Out, dr.Format())
return nil
}
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(opts.Identity),
DryRun: true,
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
}

View File

@@ -6,9 +6,12 @@ package cmdutil
import (
"bytes"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -66,11 +69,31 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
}
}
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/task/v2/tasks/:assignee_id").
Set("assignee", "ou_bot")
text := dr.Format()
if strings.Contains(text, "ou_bot_id") {
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
}
if !strings.Contains(text, ":assignee_id") {
t.Fatalf("missing unresolved placeholder, got: %s", text)
}
dr.Set("assignee_id", "ou_abc/123")
text = dr.Format()
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
}
}
func TestDryRunAPI_MarshalJSON(t *testing.T) {
dr := NewDryRunAPI().
Desc("test api").
GET("/open-apis/test").
Set("as", "user")
Set("note", "audit")
data, err := json.Marshal(dr)
if err != nil {
@@ -83,8 +106,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
if m["note"] != "audit" {
t.Errorf("expected note=audit, got: %v", m["note"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
@@ -123,31 +146,67 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, "json")
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
CommandPath: "lark-cli api",
Identity: core.AsUser,
Out: &buf,
ErrOut: &errBuf,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, got: %s", out)
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
}
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
t.Fatalf("unexpected envelope: %#v", env)
}
data, ok := env["data"].(map[string]interface{})
if !ok {
t.Fatalf("unexpected data: %#v", env["data"])
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "app123" {
t.Fatalf("unexpected data.context: %#v", data["context"])
}
if _, exists := data["as"]; exists {
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, "pretty")
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
Format: "pretty",
Identity: core.AsBot,
Out: &buf,
ErrOut: &errBuf,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
@@ -155,6 +214,136 @@ func TestPrintDryRun_Pretty(t *testing.T) {
if !strings.Contains(out, "POST /open-apis/test") {
t.Errorf("expected POST line in pretty output, got: %s", out)
}
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
}
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
}
}
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
JqExpr: ".data.api[0].url",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
}
}
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRunWithFile(client.RawApiRequest{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
As: "bot",
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
Format: "json",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
if err != nil {
t.Fatalf("PrintDryRunWithFile failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["path"] != "report.txt" {
t.Fatalf("file body = %#v", body)
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
t.Fatalf("unexpected data.context: %#v", data["context"])
}
for _, legacy := range []string{"as", "appId", "userOpenId"} {
if _, exists := data[legacy]; exists {
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
}
}
}
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "OPTIONS",
URL: "/open-apis/test",
As: "bot",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
if call["method"] != "OPTIONS" {
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
}
}
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
}, &core.CliConfig{}, DryRunOutputOptions{
Format: "json",
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
data := env["data"].(map[string]interface{})
if _, exists := data["context"]; exists {
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
}
}
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
if err == nil {
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
}
var internal *errs.InternalError
if !errors.As(err, &internal) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
}
func TestDryRunFormatValue(t *testing.T) {

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"slices"
"strings"
"github.com/larksuite/cli/errs"
)
// FormatCapabilities is the single description of the output formats a
// command supports. Help text, shell completion, shorthand normalization and
// runtime validation all consume the same value so they cannot drift apart.
type FormatCapabilities struct {
names []string
}
var (
// StandardFormats applies to API, service and ordinary shortcut commands.
StandardFormats = NewFormatCapabilities("json", "pretty", "table", "ndjson", "csv")
// JSONPrettyFormats applies to commands with a dedicated human renderer and
// no streaming/tabular output, such as auth scopes.
JSONPrettyFormats = NewFormatCapabilities("json", "pretty")
)
// NewFormatCapabilities constructs an immutable format capability set.
func NewFormatCapabilities(names ...string) FormatCapabilities {
return FormatCapabilities{names: append([]string(nil), names...)}
}
// Names returns a copy suitable for completion candidates.
func (c FormatCapabilities) Names() []string {
return append([]string(nil), c.names...)
}
// Usage returns the canonical help text for a --format flag.
func (c FormatCapabilities) Usage() string {
return "output format: " + strings.Join(c.names, "|")
}
// Supports reports whether name is part of this command's output contract.
func (c FormatCapabilities) Supports(name string) bool {
return slices.Contains(c.names, strings.ToLower(name))
}
// Resolve applies the --json shorthand and validates the selected format.
// An explicit --format always wins over --json.
func (c FormatCapabilities) Resolve(format string, formatExplicit, jsonShorthand bool) (string, error) {
if jsonShorthand && !formatExplicit {
format = "json"
}
format = strings.ToLower(format)
if c.Supports(format) {
return format, nil
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported output format %q; supported formats: %s", format, strings.Join(c.names, ", ")).
WithParam("--format")
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
)
func TestFormatCapabilitiesResolve(t *testing.T) {
tests := []struct {
name string
format string
formatExplicit bool
jsonShorthand bool
want string
wantErr bool
}{
{name: "default", format: "json", want: "json"},
{name: "json shorthand", format: "table", jsonShorthand: true, want: "json"},
{name: "explicit format wins", format: "table", formatExplicit: true, jsonShorthand: true, want: "table"},
{name: "case normalized", format: "PRETTY", formatExplicit: true, want: "pretty"},
{name: "unsupported", format: "xml", formatExplicit: true, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := StandardFormats.Resolve(tt.format, tt.formatExplicit, tt.jsonShorthand)
if tt.wantErr {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
t.Fatalf("Resolve() error = %T %v, want invalid_argument --format validation error", err, err)
}
return
}
if err != nil || got != tt.want {
t.Fatalf("Resolve() = %q, %v; want %q, nil", got, err, tt.want)
}
})
}
}
func TestFormatCapabilitiesDriveHelpAndCompletion(t *testing.T) {
if got, want := StandardFormats.Usage(), "output format: json|pretty|table|ndjson|csv"; got != want {
t.Fatalf("Usage() = %q, want %q", got, want)
}
if got, want := StandardFormats.Names(), []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(got, want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
}

View File

@@ -7,6 +7,7 @@ package output
type Envelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`

View File

@@ -9,6 +9,7 @@ import "io"
type SuccessEnvelopeOptions struct {
CommandPath string
Identity string
DryRun bool
JqExpr string
Out io.Writer
ErrOut io.Writer
@@ -41,6 +42,7 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
env := Envelope{
OK: true,
Identity: opts.Identity,
DryRun: opts.DryRun,
Data: data,
Notice: GetNotice(),
}

View File

@@ -104,6 +104,47 @@ func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
}
}
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
Identity: "bot",
DryRun: true,
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", env)
}
if _, ok := env["data"].(map[string]interface{}); !ok {
t.Fatalf("data = %#v, want object", env["data"])
}
}
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
Identity: "bot",
DryRun: true,
JqExpr: ".dry_run",
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
if strings.TrimSpace(out.String()) != "true" {
t.Fatalf("jq output = %q, want true", out.String())
}
}
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&mockProvider{

View File

@@ -103,6 +103,9 @@ func ExtractItems(data interface{}) []interface{} {
func FormatValue(w io.Writer, data interface{}, format Format) {
data = toGeneric(data)
switch format {
case FormatPretty:
PrintJson(w, data)
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
@@ -149,6 +152,9 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
switch pf.Format {
case FormatPretty:
PrintJson(pf.W, data)
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
PrintNdjson(pf.W, arr)

View File

@@ -73,6 +73,30 @@ func TestFormatValue_Table(t *testing.T) {
}
}
func TestFormatValue_Pretty(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatPretty)
out := buf.String()
if !json.Valid([]byte(out)) {
t.Fatalf("pretty output should be valid JSON, got:\n%s", out)
}
if !strings.Contains(out, "\n \"data\": {") || !strings.Contains(out, `"name": "Alice"`) {
t.Fatalf("pretty output should be indented JSON, got:\n%s", out)
}
if strings.Contains(out, "─") {
t.Fatalf("pretty output should not render a table, got:\n%s", out)
}
}
func TestFormatValue_CSV(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
@@ -149,6 +173,20 @@ func TestPaginatedFormatter_Table(t *testing.T) {
}
}
func TestPaginatedFormatter_Pretty(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatPretty)
pf.FormatPage([]interface{}{map[string]interface{}{"name": "Alice"}})
out := buf.String()
if !json.Valid([]byte(out)) {
t.Fatalf("paginated pretty output should be valid JSON, got:\n%s", out)
}
if !strings.Contains(out, "\n {") || !strings.Contains(out, `"name": "Alice"`) {
t.Fatalf("paginated pretty output should be indented JSON, got:\n%s", out)
}
}
func TestPaginatedFormatter_CSV(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatCSV)

View File

@@ -10,6 +10,7 @@ type Format int
const (
FormatJSON Format = iota
FormatPretty
FormatNDJSON
FormatTable
FormatCSV
@@ -22,6 +23,8 @@ func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
return FormatJSON, true
case "pretty":
return FormatPretty, true
case "ndjson":
return FormatNDJSON, true
case "table":
@@ -36,6 +39,8 @@ func ParseFormat(s string) (Format, bool) {
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatPretty:
return "pretty"
case FormatNDJSON:
return "ndjson"
case FormatTable:

View File

@@ -14,6 +14,9 @@ func TestParseFormat(t *testing.T) {
{"json", FormatJSON, true},
{"JSON", FormatJSON, true},
{"Json", FormatJSON, true},
{"pretty", FormatPretty, true},
{"PRETTY", FormatPretty, true},
{"Pretty", FormatPretty, true},
{"ndjson", FormatNDJSON, true},
{"NDJSON", FormatNDJSON, true},
{"Ndjson", FormatNDJSON, true},
@@ -52,6 +55,7 @@ func TestFormatString(t *testing.T) {
want string
}{
{FormatJSON, "json"},
{FormatPretty, "pretty"},
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},

View File

@@ -11,6 +11,7 @@ import (
rootcmd "github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/registry"
@@ -94,7 +95,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif
Short: c.Short,
Example: c.Example,
Hidden: c.Hidden,
Runnable: c.Runnable(),
Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c),
Source: source,
Generated: cmdmeta.Generated(c),
Identities: cmdmeta.Identities(c),

View File

@@ -90,6 +90,20 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
}
}
func TestCollectMarksPureNavigationGroupsNonRunnable(t *testing.T) {
got, err := collectCommandIndex(context.Background())
if err != nil {
t.Fatalf("collectCommandIndex() error = %v", err)
}
cmd := findManifestCommand(&got, "approval")
if cmd == nil {
t.Fatalf("approval group not found")
}
if cmd.Runnable {
t.Fatalf("approval is a navigation group and must not be exported as runnable")
}
}
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
got, err := collectHandAuthored(context.Background())
if err != nil {

View File

@@ -878,16 +878,23 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
var firstErr error
for start >= 0 {
var preview struct {
API []facts.DryRunRequest `json:"api"`
API []facts.DryRunRequest `json:"api"`
Data struct {
API []facts.DryRunRequest `json:"api"`
} `json:"data"`
}
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
if err := dec.Decode(&preview); err == nil {
if len(preview.API) == 0 {
api := preview.API
if len(api) == 0 {
api = preview.Data.API
}
if len(api) == 0 {
if firstErr == nil {
firstErr = errNoDryRunAPI
}
} else {
return preview.API[0], len(preview.API), nil
return api[0], len(api), nil
}
} else if firstErr == nil {
firstErr = err

View File

@@ -33,6 +33,17 @@ func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
}
}
func TestExtractDryRunJSONReadsSuccessEnvelope(t *testing.T) {
raw := `{"ok":true,"dry_run":true,"data":{"api":[{"method":"GET","url":"/open-apis/test"}]}}`
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
if err != nil {
t.Fatalf("extractDryRunJSON() error = %v", err)
}
if got.Method != "GET" || got.URL != "/open-apis/test" || apiCallCount != 1 {
t.Fatalf("got request=%#v apiCallCount=%d, want enveloped GET and count 1", got, 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))

View File

@@ -81,16 +81,19 @@ func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
if err != nil {
t.Fatalf("execute: %v", err)
}
var got struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
var envlp struct {
Data struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
t.Fatalf("json: %v", err)
}
got := envlp.Data
if !strings.Contains(got.Description, "HIGH-RISK") || strings.Contains(got.Description, "resolve command_id") {
t.Fatalf("top-level description must contain only the risk context: %q", got.Description)
}

View File

@@ -100,16 +100,19 @@ func TestSlashCommandUpdate_ByNameDryRunDescriptions(t *testing.T) {
if err != nil {
t.Fatalf("execute: %v", err)
}
var got struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
var envlp struct {
Data struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
Method string `json:"method"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
t.Fatalf("json: %v", err)
}
got := envlp.Data
if strings.Contains(got.Description, "resolve command_id") {
t.Fatalf("resolve description must be attached to GET, not top-level: %q", got.Description)
}
@@ -128,15 +131,18 @@ func TestSlashCommandUpdate_ByIDDryRunEncodesTrimmedPathSegment(t *testing.T) {
if err != nil {
t.Fatalf("execute: %v", err)
}
var got struct {
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
} `json:"api"`
var envlp struct {
Data struct {
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
if err := json.Unmarshal(stdout.Bytes(), &envlp); err != nil {
t.Fatalf("json: %v", err)
}
got := envlp.Data
wantURL := slashCommandBasePath + "/id%2Fwith%20space%3Fx"
if len(got.API) != 1 || got.API[0].URL != wantURL || got.API[0].Desc == "" {
t.Fatalf("dry-run call = %#v, want encoded URL %q with description", got.API, wantURL)

View File

@@ -23,19 +23,21 @@ func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
if env.Data.API[0].Method != "POST" || env.Data.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
t.Fatalf("method/url = %s %s", env.Data.API[0].Method, env.Data.API[0].URL)
}
body := env.API[0].Body
body := env.Data.API[0].Body
if _, ok := body["start_timestamp_ns"]; !ok {
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
}
@@ -92,14 +94,16 @@ func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
filter := env.API[0].Body["filter"].(map[string]interface{})
filter := env.Data.API[0].Body["filter"].(map[string]interface{})
deviceTypes := filter["device_types"].([]interface{})
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)

View File

@@ -101,14 +101,16 @@ func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
a := env.Data.API[0]
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
@@ -136,13 +138,15 @@ func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
if env.Data.API[0].Body["enabled"] != false || env.Data.API[0].Body["table"] != "orders" {
t.Fatalf("dry-run body=%v (want enabled:false)", env.Data.API[0].Body)
}
factory2, stdout2, reg := newAppsExecuteFactory(t)
@@ -178,14 +182,16 @@ func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
a := env.Data.API[0]
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
}

View File

@@ -37,13 +37,7 @@ func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbChangelogURL {

View File

@@ -71,13 +71,7 @@ func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbDataExportURL {

View File

@@ -97,14 +97,7 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbDataImportURL {
@@ -131,12 +124,11 @@ func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if len(env.API) != 1 {
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
}
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
@@ -174,11 +166,7 @@ func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Params["table"] != "customers" {
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)

View File

@@ -30,13 +30,7 @@ func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
@@ -91,11 +85,7 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["dry_run"] != false {
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
@@ -180,13 +170,7 @@ func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
@@ -331,14 +315,11 @@ func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if len(env.API) != 1 {
t.Fatalf("dry-run API calls = %d, want 1; stdout=%s", len(env.API), stdout.String())
}
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)

View File

@@ -165,14 +165,7 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
@@ -254,11 +247,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}

View File

@@ -79,11 +79,7 @@ func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) {
if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}

View File

@@ -165,13 +165,7 @@ func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -196,11 +190,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v", err)
}

View File

@@ -149,11 +149,7 @@ func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var dryRun dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -228,11 +224,7 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
t.Fatalf("dry-run missing %q: %s", want, got)
}
}
var dryRun struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var dryRun dryRunAPIEnvelope
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)
}
@@ -353,13 +345,7 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var dryRun struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var dryRun dryRunAPIEnvelope
got := stdout.String()
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, got)

View File

@@ -48,13 +48,7 @@ func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) {
[]string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != fileDeleteURL {

View File

@@ -41,12 +41,7 @@ func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) {
[]string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload {
t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL)

View File

@@ -46,13 +46,7 @@ func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) {
[]string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" {
t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params)

View File

@@ -95,13 +95,7 @@ func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -139,11 +133,7 @@ func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
if _, ok := env.API[0].Params[banned]; ok {

View File

@@ -22,13 +22,7 @@ func TestAppsFileSign_DryRunBody(t *testing.T) {
[]string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" {

View File

@@ -76,13 +76,7 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {

View File

@@ -737,15 +737,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
}
func TestAppsInit_Req1_Wording(t *testing.T) {
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
// directly rather than via parseEnvelopeData.
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
data, err := decodeDryRunDataMap(stdout.Bytes())
if err != nil {
t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String())
}
desc, _ := data["description"].(string)
@@ -1447,8 +1444,8 @@ func TestAppsInit_DryRun_DescribesEnvPull(t *testing.T) {
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &m); err != nil {
m, err := decodeDryRunDataMap(stdout.Bytes())
if err != nil {
t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String())
}
ep, _ := m["env_pull"].(string)

View File

@@ -25,13 +25,7 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) {
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}

View File

@@ -39,13 +39,7 @@ func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) {
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -90,11 +84,7 @@ func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) {
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}

View File

@@ -24,13 +24,7 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
@@ -73,13 +67,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
var env dryRunAPIEnvelope
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
)
type dryRunAPICall struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
}
type dryRunAPIEnvelope struct {
API []dryRunAPICall
}
func (e *dryRunAPIEnvelope) UnmarshalJSON(data []byte) error {
var raw struct {
Data struct {
API []dryRunAPICall `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
e.API = raw.Data.API
return nil
}
func decodeDryRunDataMap(data []byte) (map[string]interface{}, error) {
var raw struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
if raw.Data == nil {
return nil, fmt.Errorf("dry-run stdout is not a success envelope: %s", data)
}
return raw.Data, nil
}

View File

@@ -38,25 +38,27 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body interface{} `json:"body"`
} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
LocalEffects []string `json:"local_effects"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body interface{} `json:"body"`
} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
LocalEffects []string `json:"local_effects"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(payload.API) != 1 {
t.Fatalf("api len = %d, want 1", len(payload.API))
if len(payload.Data.API) != 1 {
t.Fatalf("api len = %d, want 1", len(payload.Data.API))
}
call := payload.API[0]
call := payload.Data.API[0]
if call.Method != "GET" {
t.Fatalf("method = %q, want GET", call.Method)
}
@@ -69,19 +71,19 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
if call.Body != nil {
t.Fatalf("body = %#v, want nil", call.Body)
}
if payload.Mode != "api-plus-local-setup" {
t.Fatalf("mode = %q", payload.Mode)
if payload.Data.Mode != "api-plus-local-setup" {
t.Fatalf("mode = %q", payload.Data.Mode)
}
if payload.Action != "initialize_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
if payload.Data.Action != "initialize_local_git_credential" {
t.Fatalf("action = %q", payload.Data.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
if payload.Data.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.Data.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
}
assertStringSliceEqual(t, payload.LocalEffects, []string{
assertStringSliceEqual(t, payload.Data.LocalEffects, []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
@@ -96,32 +98,34 @@ func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
StorageRoot string `json:"storage_root"`
Reads []string `json:"reads"`
Data struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
StorageRoot string `json:"storage_root"`
Reads []string `json:"reads"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
t.Fatalf("description = %q", payload.Description)
if payload.Data.Description != "Preview local Git credential listing (no API call, read-only local state)." {
t.Fatalf("description = %q", payload.Data.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
if len(payload.Data.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
}
if payload.Mode != "local-read-only" {
t.Fatalf("mode = %q", payload.Mode)
if payload.Data.Mode != "local-read-only" {
t.Fatalf("mode = %q", payload.Data.Mode)
}
if payload.Action != "list_local_git_credentials" {
t.Fatalf("action = %q", payload.Action)
if payload.Data.Action != "list_local_git_credentials" {
t.Fatalf("action = %q", payload.Data.Action)
}
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
t.Fatalf("storage_root = %q", payload.StorageRoot)
if !strings.HasSuffix(payload.Data.StorageRoot, filepath.Join("spark")) {
t.Fatalf("storage_root = %q", payload.Data.StorageRoot)
}
assertStringSliceEqual(t, payload.Reads, []string{
assertStringSliceEqual(t, payload.Data.Reads, []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
@@ -135,36 +139,38 @@ func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
Effects []string `json:"effects"`
Data struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
Effects []string `json:"effects"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
t.Fatalf("description = %q", payload.Description)
if payload.Data.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
t.Fatalf("description = %q", payload.Data.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
if len(payload.Data.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.Data.API))
}
if payload.Mode != "local-cleanup-only" {
t.Fatalf("mode = %q", payload.Mode)
if payload.Data.Mode != "local-cleanup-only" {
t.Fatalf("mode = %q", payload.Data.Mode)
}
if payload.Action != "remove_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
if payload.Data.Action != "remove_local_git_credential" {
t.Fatalf("action = %q", payload.Data.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
if payload.Data.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.Data.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
if !strings.HasSuffix(payload.Data.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.Data.MetadataFile)
}
assertStringSliceEqual(t, payload.Effects, []string{
assertStringSliceEqual(t, payload.Data.Effects, []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",

View File

@@ -1222,7 +1222,7 @@ func TestAgenda_Success(t *testing.T) {
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--format", "prettry",
"--format", "pretty",
"--as", "bot",
}, f, stdout)

View File

@@ -752,12 +752,9 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre
}
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
outFn := ctx.Out
if raw {
outFn = ctx.OutRaw
}
emitJSON := func() { ctx.emit(data, meta, raw, true) }
if ctx.JqExpr != "" {
outFn(data, meta)
emitJSON()
return
}
switch ctx.Format {
@@ -773,10 +770,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
if prettyFn != nil {
prettyFn(ctx.IO().Out)
} else {
outFn(data, meta)
output.FormatValue(ctx.IO().Out, data, output.FormatPretty)
}
case "json", "":
outFn(data, meta)
emitJSON()
default:
// table, csv, ndjson — pass data directly; FormatValue handles both
// plain arrays and maps with array fields (e.g. {"members":[…]})
@@ -790,7 +787,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty
}
format, formatOK := output.ParseFormat(ctx.Format)
if !formatOK {
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
ctx.outputErrOnce.Do(func() {
ctx.outputErr = errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported output format %q", ctx.Format).WithParam("--format")
})
return
}
output.FormatValue(ctx.IO().Out, data, format)
}
@@ -928,6 +929,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
}
}
// Output format validation is local and must happen before identity,
// configuration or credential work. Invalid input therefore produces the
// same typed validation error even when no account is configured.
if _, err := normalizeShortcutOutputFormat(cmd, s); err != nil {
return err
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
@@ -1027,8 +1035,11 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
}
rctx.larkSDK = sdk
applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
format, err := normalizeShortcutOutputFormat(cmd, s)
if err != nil {
return nil, err
}
rctx.Format = format
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
}
@@ -1153,14 +1164,19 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
}
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
dryResult := s.DryRun(rctx.ctx, rctx)
if rctx.Format == "pretty" {
fmt.Fprint(f.IOStreams.Out, dryResult.Format())
} else {
output.PrintJson(f.IOStreams.Out, dryResult)
if dryResult != nil {
// Same data.context contract as the service/api dry-run paths.
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
}
return nil
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
Format: rctx.Format,
JqExpr: rctx.JqExpr,
CommandPath: rctx.Cmd.CommandPath(),
Identity: rctx.As(),
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
})
}
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
@@ -1195,15 +1211,23 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.
// "json". It derives the answer from the same capability set used everywhere
// else (shortcutFormatCapabilities), so a format flag that declares no Enum but
// defaults to "json" is correctly recognized as JSON-capable.
func shortcutFormatSupportsJSON(s *Shortcut) bool {
return shortcutFormatCapabilities(s).Supports("json")
}
func shortcutFormatCapabilities(s *Shortcut) output.FormatCapabilities {
for _, fl := range s.Flags {
if fl.Name == "format" {
return slices.Contains(fl.Enum, "json")
if len(fl.Enum) > 0 {
return output.NewFormatCapabilities(fl.Enum...)
}
return output.NewFormatCapabilities(fl.Default)
}
}
return true // framework-injected: json (default) | pretty | table | ndjson | csv
return output.StandardFormats
}
// ensureJSONShorthand registers --json as a shorthand for --format json when:
@@ -1238,15 +1262,32 @@ func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
// shorthand only fills in when the user did not choose a format). Shortcuts
// that declare their own "json" flag keep its custom semantics untouched.
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
if shortcutDeclaresJSONFlag(s) {
return
_, _ = normalizeShortcutOutputFormat(cmd, s)
}
func normalizeShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
format, err := resolveShortcutOutputFormat(cmd, s)
if err != nil {
return "", err
}
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
return
if cmd.Flags().Lookup("format") != nil && cmd.Flags().Lookup("format").Value.String() != format {
if err := cmd.Flags().Set("format", format); err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to normalize --format: %v", err).WithCause(err)
}
}
if set, _ := cmd.Flags().GetBool("json"); set {
_ = cmd.Flags().Set("format", "json")
return format, nil
}
func resolveShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) {
format, err := cmd.Flags().GetString("format")
if err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to read --format: %v", err).WithCause(err)
}
jsonShorthand := false
if !shortcutDeclaresJSONFlag(s) && cmd.Flags().Lookup("json") != nil {
jsonShorthand, _ = cmd.Flags().GetBool("json")
}
return shortcutFormatCapabilities(s).Resolve(format, cmd.Flags().Changed("format"), jsonShorthand)
}
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
@@ -1308,9 +1349,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmd.Flags().Bool("dry-run", false, "print request without executing")
if cmd.Flags().Lookup("format") == nil {
cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv")
cmd.Flags().String("format", "json", output.StandardFormats.Usage())
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
}
ensureJSONShorthand(cmd, s)

View File

@@ -5,9 +5,13 @@ package common
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
@@ -37,3 +41,87 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
}
}
func TestRuntimeContextOutKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
rctx.Format = "pretty"
rctx.Out(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
}, nil)
var envelope struct {
OK bool `json:"ok"`
Data struct {
Items []map[string]interface{} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("Out should emit a JSON envelope: %v\n%s", err, stdout.String())
}
if !envelope.OK || len(envelope.Data.Items) != 1 || envelope.Data.Items[0]["name"] != "Alice" {
t.Fatalf("unexpected envelope: %#v", envelope)
}
}
func TestRuntimeContextOutRawKeepsJSONEnvelopeForPrettyFormat(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot)
rctx.Format = "pretty"
rctx.OutRaw(map[string]interface{}{"body": "<p>hello</p>"}, nil)
var envelope struct {
OK bool `json:"ok"`
Data struct {
Body string `json:"body"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("OutRaw should emit a JSON envelope: %v\n%s", err, stdout.String())
}
if !envelope.OK || envelope.Data.Body != "<p>hello</p>" {
t.Fatalf("unexpected envelope: %#v", envelope)
}
}
func TestShortcutMount_UnsupportedFormatFailsBeforeExecution(t *testing.T) {
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
parent := &cobra.Command{Use: "root"}
executed := false
shortcut := Shortcut{
Service: "test",
Command: "+read",
Description: "read data",
AuthTypes: []string{"bot"},
Execute: func(context.Context, *RuntimeContext) error {
executed = true
return nil
},
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+read"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if cmd == nil {
t.Fatal("expected mounted shortcut command")
}
parent.SetArgs([]string{"+read", "--format", "xml"})
err = parent.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--format" {
t.Fatalf("param = %q, want --format", validationErr.Param)
}
if executed {
t.Fatal("shortcut must not execute with an unsupported format")
}
}

View File

@@ -6,6 +6,7 @@ package common
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
@@ -229,6 +230,75 @@ func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
}
}
func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
stdout := f.IOStreams.Out.(*bytes.Buffer)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", env)
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", call)
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "test" {
t.Fatalf("runner must inject data.context like the service/api paths, got: %#v", data["context"])
}
}
func TestRunShortcut_DryRunWithJq(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("jq", ".dry_run")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
stdout := f.IOStreams.Out.(*bytes.Buffer)
if got := strings.TrimSpace(stdout.String()); got != "true" {
t.Fatalf("jq output = %q, want true", got)
}
}
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")

View File

@@ -45,6 +45,16 @@ func decodeJSONMap(t *testing.T, raw string) map[string]interface{} {
return data
}
func dryRunDataMap(t *testing.T, raw string) map[string]interface{} {
t.Helper()
out := decodeJSONMap(t, raw)
data, ok := out["data"].(map[string]interface{})
if !ok {
t.Fatalf("dry-run data is %T, want map[string]interface{}\nstdout:\n%s", out["data"], raw)
}
return data
}
func mustMapValue(t *testing.T, value interface{}, path string) map[string]interface{} {
t.Helper()
@@ -1628,8 +1638,8 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
if !strings.Contains(stdout.String(), "slide block comment") {
t.Fatalf("dry-run output missing slide block comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
@@ -1656,8 +1666,8 @@ func TestDryRunBaseDirectURL(t *testing.T) {
if !strings.Contains(stdout.String(), "record-local comment") {
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
@@ -1699,8 +1709,8 @@ func TestDryRunWikiResolvesToSlides(t *testing.T) {
if !strings.Contains(stdout.String(), "slide block comment") {
t.Fatalf("dry-run output missing slide block comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
@@ -1736,8 +1746,8 @@ func TestDryRunWikiSlidesInvalidBlockIDSurfaces(t *testing.T) {
if !strings.Contains(stdout.String(), "slide --block-id must be") || !strings.Contains(stdout.String(), "shape_2") {
t.Fatalf("dry-run output missing block-id format error: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 0 {
t.Fatalf("dry-run should not preview API calls with malformed block-id: %s", stdout.String())
}
@@ -1821,8 +1831,8 @@ func TestDryRunFileDirectURL(t *testing.T) {
if !strings.Contains(stdout.String(), "verify supported file metadata") {
t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
out := dryRunDataMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "data.api")
if len(api) != 2 {
t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String())
}

View File

@@ -518,15 +518,17 @@ func TestDriveMemberAdd_PermDefaultsToView(t *testing.T) {
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if got.API[0].Body["perm"] != "view" {
t.Fatalf("perm = %v, want view", got.API[0].Body["perm"])
if got.Data.API[0].Body["perm"] != "view" {
t.Fatalf("perm = %v, want view", got.Data.API[0].Body["perm"])
}
}
@@ -625,18 +627,20 @@ func TestDriveMemberAdd_DryRunAcceptsAppID(t *testing.T) {
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if got.API[0].Body["member_type"] != "appid" {
t.Fatalf("member_type = %v, want appid", got.API[0].Body["member_type"])
if got.Data.API[0].Body["member_type"] != "appid" {
t.Fatalf("member_type = %v, want appid", got.Data.API[0].Body["member_type"])
}
if _, ok := got.API[0].Body["type"]; ok {
t.Fatalf("type = %v, want omitted for appid", got.API[0].Body["type"])
if _, ok := got.Data.API[0].Body["type"]; ok {
t.Fatalf("type = %v, want omitted for appid", got.Data.API[0].Body["type"])
}
}
@@ -660,15 +664,17 @@ func TestDriveMemberAdd_DryRunAcceptsWikiSpaceID(t *testing.T) {
}
var got struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if got.API[0].Body["member_type"] != "wikispaceid" || got.API[0].Body["type"] != "wiki_space_viewer" {
t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.API[0].Body)
if got.Data.API[0].Body["member_type"] != "wikispaceid" || got.Data.API[0].Body["type"] != "wiki_space_viewer" {
t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.Data.API[0].Body)
}
}
@@ -792,20 +798,22 @@ func TestDriveMemberAdd_DryRunInfersTypeAndDefaultsWikiPermType(t *testing.T) {
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.API) != 1 {
t.Fatalf("api count = %d, want 1; stdout=%s", len(got.API), stdout.String())
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1; stdout=%s", len(got.Data.API), stdout.String())
}
api := got.API[0]
api := got.Data.API[0]
if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/wikTok/members" {
t.Fatalf("api = %#v", api)
}
@@ -836,19 +844,21 @@ func TestDriveMemberAdd_DryRunAcceptsUppercaseEnumsForDocx(t *testing.T) {
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if got.API[0].Params["type"] != "docx" {
t.Fatalf("params.type = %v, want docx", got.API[0].Params["type"])
if got.Data.API[0].Params["type"] != "docx" {
t.Fatalf("params.type = %v, want docx", got.Data.API[0].Params["type"])
}
if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" {
t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body)
if got.Data.API[0].Body["member_type"] != "openid" || got.Data.API[0].Body["perm"] != "edit" {
t.Fatalf("body = %#v, want canonical lowercase enum values", got.Data.API[0].Body)
}
}
@@ -872,19 +882,21 @@ func TestDriveMemberAdd_DryRunAcceptsUppercaseWikiPermType(t *testing.T) {
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if got.API[0].Params["type"] != "wiki" {
t.Fatalf("params.type = %v, want wiki", got.API[0].Params["type"])
if got.Data.API[0].Params["type"] != "wiki" {
t.Fatalf("params.type = %v, want wiki", got.Data.API[0].Params["type"])
}
if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" || got.API[0].Body["perm_type"] != "container" {
t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body)
if got.Data.API[0].Body["member_type"] != "openid" || got.Data.API[0].Body["perm"] != "edit" || got.Data.API[0].Body["perm_type"] != "container" {
t.Fatalf("body = %#v, want canonical lowercase enum values", got.Data.API[0].Body)
}
}
@@ -949,19 +961,21 @@ func TestDriveMemberAdd_DryRunBatch(t *testing.T) {
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.API))
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.Data.API))
}
api := got.API[0]
api := got.Data.API[0]
if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/shtcnTok/members/batch_create" {
t.Fatalf("api = %#v", api)
}

View File

@@ -1,286 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/errs"
)
// flagName is a package-private snapshot of a pflag.Flag's identity.
type flagName struct {
long, short string
hidden bool
}
// Candidate is a single suggested flag returned to the user when an
// unknown flag is detected.
type Candidate struct {
// Flag is the long-form spelling of the suggested flag, e.g. "--to".
Flag string `json:"flag"`
// Shorthand is the single-character shorthand (without the leading
// dash) when the suggested flag has one; empty otherwise.
Shorthand string `json:"shorthand,omitempty"`
// Distance is the Levenshtein edit distance to the unknown token.
// Zero indicates a bidirectional prefix hit (Reason == "prefix").
Distance int `json:"distance"`
// Reason explains how the candidate was matched: "prefix" for
// bidirectional prefix hits, "edit_distance" for fuzzy matches.
Reason string `json:"reason"`
}
// maxCandidates caps the number of suggestions returned per error so
// the JSON envelope stays compact and the user-visible hint remains
// scannable.
const maxCandidates = 5
// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail
// service cobra parent command. It is invoked exactly once from
// shortcuts/register.go inside the `service == "mail"` branch.
//
// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest
// non-nil hook, so every mail subcommand inherits this behaviour without
// any per-shortcut wiring.
func InstallOnMail(svc *cobra.Command) {
if svc == nil {
return
}
svc.SetFlagErrorFunc(flagSuggestErrorFunc)
}
// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed
// validation error carrying candidate suggestions. Any other error is passed
// through unchanged so cobra's existing handling kicks in.
func flagSuggestErrorFunc(c *cobra.Command, err error) error {
if err == nil {
return nil
}
token, isShorthand, ok := parseUnknownToken(err.Error())
if !ok {
// Non unknown-flag errors (e.g. "required flag(s) ... not set")
// pass through to cmd/root.go::handleRootError's fallback path.
return err
}
names := collectFlags(c)
var matches []Candidate
if isShorthand {
matches = suggestShorthand(token, names)
} else {
matches = suggest(token, names)
}
// Normalise to a non-nil slice so the JSON envelope always emits
// `candidates: []` instead of `null`, keeping the wire shape stable
// for downstream parsers regardless of command-state.
if matches == nil {
matches = []Candidate{}
}
hint := buildHint(c, matches)
params := []errs.InvalidParam{{
Name: rawUnknownToken(token, isShorthand),
Reason: "unknown flag",
}}
for _, match := range matches {
reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance)
if match.Shorthand != "" {
reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand)
}
params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason})
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()).
WithHint("%s", hint).
WithParam(rawUnknownToken(token, isShorthand)).
WithParams(params...)
}
// parseUnknownToken extracts the offending flag name from a pflag error
// string. Recognised forms:
//
// - "unknown flag: --tos"
// - "unknown flag: --bogus=val"
// - "unknown shorthand flag: 'X' in -Xyz"
//
// Anything else returns (_, _, false) so the caller can pass the error
// through unchanged.
func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) {
const longPrefix = "unknown flag: --"
const shortPrefix = "unknown shorthand flag: '"
switch {
case strings.HasPrefix(errMsg, longPrefix):
rest := errMsg[len(longPrefix):]
if eq := strings.IndexAny(rest, "= \t"); eq >= 0 {
rest = rest[:eq]
}
return rest, false, rest != ""
case strings.HasPrefix(errMsg, shortPrefix):
rest := errMsg[len(shortPrefix):]
end := strings.IndexByte(rest, '\'')
if end <= 0 {
return "", false, false
}
return rest[:end], true, true
}
return "", false, false
}
// rawUnknownToken re-attaches the leading dash(es) to a bare token so the
// JSON envelope echoes the user-visible spelling.
func rawUnknownToken(token string, isShorthand bool) string {
if isShorthand {
return "-" + token
}
return "--" + token
}
// collectFlags snapshots the merged local + persistent + inherited flag
// set of cmd. The hidden bit is preserved on each entry; the suggest
// helpers apply the actual filter so the slice stays reusable.
func collectFlags(cmd *cobra.Command) []flagName {
if cmd == nil {
return nil
}
var out []flagName
cmd.Flags().VisitAll(func(f *pflag.Flag) {
out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden})
})
return out
}
// suggest produces top-N long-flag candidates for an unknown token, using
// bidirectional prefix matching first and Levenshtein distance for the
// remainder. Hidden flags and empty long names are skipped. Results are
// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates.
func suggest(unknown string, names []flagName) []Candidate {
if unknown == "" || len(names) == 0 {
return nil
}
threshold := levThreshold(unknown)
out := make([]Candidate, 0, len(names))
seen := make(map[string]struct{}, len(names))
// Priority 1: bidirectional prefix match.
for _, n := range names {
if n.hidden || n.long == "" {
continue
}
if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
seen[n.long] = struct{}{}
}
}
// Priority 2: Levenshtein distance, skipping already-matched names.
for _, n := range names {
if n.hidden || n.long == "" {
continue
}
if _, ok := seen[n.long]; ok {
continue
}
if d := levenshtein(unknown, n.long); d <= threshold {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"})
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Distance != out[j].Distance {
return out[i].Distance < out[j].Distance
}
return out[i].Flag < out[j].Flag
})
if len(out) > maxCandidates {
out = out[:maxCandidates]
}
return out
}
// suggestShorthand produces candidates for an unknown single-character
// shorthand. It first looks for exact f.Shorthand matches; if there are
// none, it falls back to long names that begin with the same character.
// Levenshtein is deliberately not used here since single-char edit
// distance would match almost every flag.
func suggestShorthand(c string, names []flagName) []Candidate {
if c == "" || len(names) == 0 {
return nil
}
out := make([]Candidate, 0)
for _, n := range names {
if n.hidden {
continue
}
if n.short == c {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
}
}
if len(out) == 0 {
for _, n := range names {
if n.hidden || n.long == "" {
continue
}
if strings.HasPrefix(n.long, c) {
out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"})
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag })
if len(out) > maxCandidates {
out = out[:maxCandidates]
}
return out
}
// buildHint returns a one-line hint suitable for a typed error's Hint field.
// When at least one candidate exists, the top hit is named; otherwise
// the user is directed to --help.
func buildHint(c *cobra.Command, matches []Candidate) string {
if len(matches) == 0 {
return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath())
}
return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag)
}
// levThreshold returns the maximum acceptable Levenshtein distance for a
// token of the given length, clamped to [1, 4].
func levThreshold(s string) int {
t := len(s)/3 + 1
if t < 1 {
return 1
}
if t > 4 {
return 4
}
return t
}
// levenshtein computes the standard Levenshtein edit distance between
// two ASCII strings using a 2-row dynamic-programming table.
func levenshtein(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[lb]
}

View File

@@ -1,353 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/larksuite/cli/errs"
)
// --- suggest (long-flag) ---
func TestSuggest_Prefix(t *testing.T) {
names := []flagName{
{long: "to", short: "t"},
{long: "cc"},
{long: "subject", short: "s"},
}
got := suggest("tos", names)
require.NotEmpty(t, got)
// "tos" has --to as a prefix → bidirectional prefix hit, Distance=0.
assert.Equal(t, "--to", got[0].Flag)
assert.Equal(t, 0, got[0].Distance)
assert.Equal(t, "prefix", got[0].Reason)
}
func TestSuggest_Levenshtein(t *testing.T) {
names := []flagName{
{long: "subject"},
{long: "body"},
{long: "to"},
}
// Distance 1 from "subject".
got := suggest("subjec", names)
require.NotEmpty(t, got)
// "subjec" is prefix of "subject" → bidirectional prefix.
assert.Equal(t, "--subject", got[0].Flag)
assert.Equal(t, "prefix", got[0].Reason)
// True edit-distance: "subjeect" is not a prefix either way of "subject".
got = suggest("subjeect", names)
require.NotEmpty(t, got)
assert.Equal(t, "--subject", got[0].Flag)
assert.Equal(t, "edit_distance", got[0].Reason)
assert.GreaterOrEqual(t, got[0].Distance, 1)
}
func TestSuggest_HiddenSkipped(t *testing.T) {
names := []flagName{
{long: "internal-debug", hidden: true},
{long: "interactive"},
}
got := suggest("internal", names)
for _, c := range got {
assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions")
}
}
func TestSuggest_TopNAndStableSort(t *testing.T) {
// 6 names all within threshold and at the same distance (1) from the
// unknown token so that the lexicographic tiebreak and maxCandidates
// cap are both exercised. (Earlier the names were 3-distance from
// "zzz" which is above the threshold of 2 — suggest returned empty
// and the assertions trivially passed.)
names := []flagName{
{long: "aaab"},
{long: "aaac"},
{long: "aaad"},
{long: "aaae"},
{long: "aaaf"},
{long: "aaag"},
}
got := suggest("aaaa", names)
require.Len(t, got, maxCandidates, "must cap at maxCandidates")
// All distances equal → lex ordering by Flag asc, top 5 alphabetically.
wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"}
gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag}
assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc")
}
// --- suggestShorthand ---
func TestSuggestShorthand_Exact(t *testing.T) {
names := []flagName{
{long: "to", short: "t"},
{long: "cc", short: "c"},
{long: "subject", short: "s"},
}
got := suggestShorthand("t", names)
require.NotEmpty(t, got)
assert.Equal(t, "--to", got[0].Flag)
assert.Equal(t, "t", got[0].Shorthand)
assert.Equal(t, "prefix", got[0].Reason)
}
func TestSuggestShorthand_PrefixFallback(t *testing.T) {
// No short matches "x"; fall back to long names starting with "x".
names := []flagName{
{long: "xargs"},
{long: "xterm"},
{long: "yargs"},
}
got := suggestShorthand("x", names)
require.NotEmpty(t, got)
flags := make([]string, 0, len(got))
for _, c := range got {
flags = append(flags, c.Flag)
}
assert.Contains(t, flags, "--xargs")
assert.Contains(t, flags, "--xterm")
assert.NotContains(t, flags, "--yargs")
}
// --- parseUnknownToken ---
func TestParseUnknownToken_Long(t *testing.T) {
tok, isShort, ok := parseUnknownToken("unknown flag: --tos")
assert.True(t, ok)
assert.False(t, isShort)
assert.Equal(t, "tos", tok)
tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val")
assert.True(t, ok)
assert.False(t, isShort)
assert.Equal(t, "bogus", tok, "must strip =value tail")
tok, _, ok = parseUnknownToken("unknown flag: --bogus value")
assert.True(t, ok)
assert.Equal(t, "bogus", tok, "must strip whitespace tail")
}
func TestParseUnknownToken_Shorthand(t *testing.T) {
tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X")
assert.True(t, ok)
assert.True(t, isShort)
assert.Equal(t, "X", tok)
tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs")
assert.True(t, ok)
assert.True(t, isShort)
assert.Equal(t, "q", tok)
}
func TestParseUnknownToken_NotMatch(t *testing.T) {
cases := []string{
`required flag(s) "to" not set`,
"some unrelated error",
"",
"unknown command \"foo\" for \"mail\"",
}
for _, in := range cases {
tok, isShort, ok := parseUnknownToken(in)
assert.False(t, ok, "input %q must not match", in)
assert.False(t, isShort)
assert.Equal(t, "", tok)
}
}
// --- flagSuggestErrorFunc ---
// newFakeMailCmd builds a cobra command tree resembling the mail parent
// with a handful of flags exercised by the hook tests.
func newFakeMailCmd() *cobra.Command {
c := &cobra.Command{Use: "mail"}
c.Flags().String("to", "", "recipients")
c.Flags().String("cc", "", "cc recipients")
c.Flags().String("subject", "", "subject")
c.Flags().StringP("body", "b", "", "body")
return c
}
func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError {
t.Helper()
var validationErr *errs.ValidationError
require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got)
p, ok := errs.ProblemOf(got)
require.True(t, ok, "expected typed Problem")
assert.Equal(t, errs.CategoryValidation, p.Category)
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
return validationErr
}
func paramReason(params []errs.InvalidParam, name string) (string, bool) {
for _, p := range params {
if p.Name == name {
return p.Reason, true
}
}
return "", false
}
func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "unknown flag: --tos", validationErr.Message)
assert.Equal(t, "--tos", validationErr.Param)
assert.Contains(t, validationErr.Hint, "--to")
reason, ok := paramReason(validationErr.Params, "--tos")
require.True(t, ok, "unknown flag should be included in params")
assert.Equal(t, "unknown flag", reason)
reason, ok = paramReason(validationErr.Params, "--to")
require.True(t, ok, "expected --to in candidate params")
assert.Contains(t, reason, "candidate (prefix")
}
func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) {
cmd := newFakeMailCmd()
in := errors.New(`required flag(s) "to" not set`)
got := flagSuggestErrorFunc(cmd, in)
// Identity passthrough: same error pointer.
assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged")
}
func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos"))
p, ok := errs.ProblemOf(got)
require.True(t, ok)
assert.Equal(t, errs.CategoryValidation, p.Category)
assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype)
}
// --- edge-case coverage ---
func TestInstallOnMail_NilIsNoop(t *testing.T) {
// Must not panic; the nil-guard is the contract.
InstallOnMail(nil)
}
func TestInstallOnMail_InstallsHook(t *testing.T) {
c := newFakeMailCmd()
InstallOnMail(c)
require.NotNil(t, c.FlagErrorFunc())
got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "--tos", validationErr.Param)
}
func TestFlagSuggestErrorFunc_NilError(t *testing.T) {
cmd := newFakeMailCmd()
assert.NoError(t, flagSuggestErrorFunc(cmd, nil))
}
func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing")
reason, ok := paramReason(validationErr.Params, "--tos")
require.True(t, ok)
assert.Equal(t, "unknown flag", reason)
}
func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) {
cmd := newFakeMailCmd()
got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Equal(t, "-b", validationErr.Param)
reason, ok := paramReason(validationErr.Params, "-b")
require.True(t, ok)
assert.Equal(t, "unknown flag", reason)
// newFakeMailCmd has --body/-b; exact shorthand hit expected.
reason, ok = paramReason(validationErr.Params, "--body")
require.True(t, ok)
assert.Contains(t, reason, "candidate (prefix")
assert.Contains(t, reason, "shorthand=-b")
}
func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) {
// A cobra command with no flags forces collectFlags → empty names →
// suggest → nil. The typed validation error must still expose the unknown
// flag in Params so downstream parsers have a stable structured field.
bare := &cobra.Command{Use: "mail"}
got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus"))
validationErr := requireFlagSuggestValidation(t, got)
assert.NotNil(t, validationErr.Params)
require.Len(t, validationErr.Params, 1)
assert.Equal(t, "--bogus", validationErr.Params[0].Name)
assert.Equal(t, "unknown flag", validationErr.Params[0].Reason)
}
func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) {
cmd := newFakeMailCmd()
// Token with no plausible neighbor in {to, cc, subject, body}.
got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz"))
validationErr := requireFlagSuggestValidation(t, got)
assert.Contains(t, validationErr.Hint, "--help")
}
func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) {
// Long form with empty token after the prefix.
_, _, ok := parseUnknownToken("unknown flag: --")
assert.False(t, ok, "empty long token must not match")
// Shorthand with no closing quote.
_, _, ok = parseUnknownToken("unknown shorthand flag: 'q")
assert.False(t, ok, "shorthand without closing quote must not match")
// Shorthand with empty char between quotes.
_, _, ok = parseUnknownToken("unknown shorthand flag: '' in -")
assert.False(t, ok, "empty shorthand token must not match")
}
func TestSuggest_EmptyInputs(t *testing.T) {
assert.Nil(t, suggest("", []flagName{{long: "to"}}))
assert.Nil(t, suggest("foo", nil))
}
func TestSuggestShorthand_EmptyInputs(t *testing.T) {
assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}}))
assert.Nil(t, suggestShorthand("x", nil))
}
func TestSuggestShorthand_HiddenSkipped(t *testing.T) {
names := []flagName{
{long: "secret", short: "s", hidden: true},
{long: "subject", short: "s"},
}
got := suggestShorthand("s", names)
for _, c := range got {
assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested")
}
}
func TestCollectFlags_NilSafe(t *testing.T) {
assert.Nil(t, collectFlags(nil))
}
func TestLevThreshold_Clamp(t *testing.T) {
// len 0 → 0/3+1 = 1
assert.Equal(t, 1, levThreshold(""))
// len 3 → 2
assert.Equal(t, 2, levThreshold("abc"))
// Long token caps at 4.
assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa"))
}
func TestLevenshtein_EmptyAndIdentical(t *testing.T) {
assert.Equal(t, 0, levenshtein("", ""))
assert.Equal(t, 3, levenshtein("", "abc"))
assert.Equal(t, 3, levenshtein("abc", ""))
assert.Equal(t, 0, levenshtein("abc", "abc"))
assert.Equal(t, 1, levenshtein("abc", "abd"))
}

View File

@@ -103,8 +103,8 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
if ve.Param != "--format" {
t.Fatalf("param = %q, want --format", ve.Param)
}
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
t.Fatalf("message = %q, want enum validation message", problem.Message)
if !strings.Contains(problem.Message, `unsupported output format "bogus"`) {
t.Fatalf("message = %q, want unsupported format message", problem.Message)
}
if !strings.Contains(problem.Message, "table, json, data") {
t.Fatalf("message = %q, want allowed values list", problem.Message)

View File

@@ -157,9 +157,6 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
if service == "apps" {
apps.InstallOnApps(svc, f)
}
if service == "mail" {
mail.InstallOnMail(svc)
}
if service == "sheets" {
applySheetsCompatGroups(svc)
}

View File

@@ -7,14 +7,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -400,68 +398,6 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) {
}
}
// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end
// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures
// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in
// RegisterShortcutsWithContext is actually exercised, so a future refactor
// that drops the branch (or breaks the import) will fail this test rather
// than silently regressing the structured-error contract.
func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
mailCmd, _, err := program.Find([]string{"mail"})
if err != nil {
t.Fatalf("find mail command: %v", err)
}
if mailCmd == nil || mailCmd.Name() != "mail" {
t.Fatalf("mail command not mounted: %#v", mailCmd)
}
// The FlagErrorFunc lookup walks up to the nearest non-nil hook, so
// invoking it on the mail parent (or any of its children) must yield
// a typed validation problem for the unknown flag.
got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus"))
var validationErr *errs.ValidationError
if !errors.As(got, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", got, got)
}
if validationErr.Param != "--bogus" {
t.Fatalf("expected Param=--bogus, got %q", validationErr.Param)
}
problem, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", got, got)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %s/%s", problem.Category, problem.Subtype)
}
}
// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the
// install is scoped: a non-mail service must keep the default cobra
// pass-through behaviour, otherwise an accidental fall-through in
// register.go would silently change every domain's error envelope.
func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
baseCmd, _, err := program.Find([]string{"base"})
if err != nil {
t.Fatalf("find base command: %v", err)
}
in := errors.New("unknown flag: --bogus")
got := baseCmd.FlagErrorFunc()(baseCmd, in)
// Default cobra hook is identity — anything else means the mail hook
// (which wraps into a typed *errs.ValidationError) leaked across domains.
if errs.IsTyped(got) {
t.Fatalf("base service unexpectedly produced a typed error: %#v", got)
}
if got != in {
t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got)
}
}
func TestGenerateShortcutsJSON(t *testing.T) {
output := os.Getenv("SHORTCUTS_OUTPUT")
if output == "" {

View File

@@ -4,16 +4,12 @@
package sheets
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// ─── sheets flag ergonomics ─────────────────────────────────────────────
@@ -28,106 +24,17 @@ import (
// needs no change at all and no other domain's behavior shifts.
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
// the command gets the sheets-specific unknown-flag error (valid flags
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
// typos suggested).
// the command gets enum-value normalization. Unknown-flag handling is owned by
// cmd/root.go's single FlagErrorFunc, which retains the sheets inline flag list.
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
}
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
// set: hallucinated sheets flags are usually semantic guesses (--cols for
// --range) that edit distance can't rank, and a --help round trip costs an
// agent a full extra call. One line here lets it re-issue the command
// immediately.
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagFromParseError(ferr)
if !isUnknown {
return common.ValidationErrorf("%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if list := inlineFlagList(valid); list != "" {
hint = "valid flags: " + list
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
strings.Join(suggestions, ", "), list)
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagFromParseError extracts the offending long-flag name from
// cobra's flag-parse error text ("unknown flag: --query" → "query").
// Returns ok=false for anything else (missing argument, invalid value,
// unknown shorthand) so those stay structured but generic. Mirrors the
// root-level parser in cmd; the prefix contract is cobra's English wording.
func unknownFlagFromParseError(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// inlineFlagListLimit caps how many flag names ride inline on an
// unknown-flag hint. Sheets shortcuts stay well under it.
const inlineFlagListLimit = 25
// inlineFlagList renders valid flag names as one comma-separated line for
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
// there is nothing to list.
func inlineFlagList(names []string) string {
if len(names) == 0 {
return ""
}
shown := names
var suffix string
if len(names) > inlineFlagListLimit {
shown = names[:inlineFlagListLimit]
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
}
parts := make([]string, len(shown))
for i, n := range shown {
parts[i] = "--" + n
}
return strings.Join(parts, ", ") + suffix
}
// ─── enum vocabulary normalization ──────────────────────────────────────
// enumAliases maps habitual values agents import from CSS / Excel / Google

View File

@@ -5,7 +5,6 @@ package sheets
import (
"errors"
"fmt"
"strings"
"testing"
@@ -14,122 +13,6 @@ import (
"github.com/spf13/cobra"
)
func TestUnknownFlagFromParseError(t *testing.T) {
t.Parallel()
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --cols", "cols", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagFromParseError(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
// override of the root unknown-flag error: --cols is a semantic guess for
// --range that edit distance can't rank, so the hint must inline the full
// valid-flag list instead of deferring to a --help round trip.
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Int("width", 0, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
}
if strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
}
}
}
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
// the sheets override, with the valid-flag list appended.
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Bool("dry-run", false, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
}
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
t.Parallel()
if got := inlineFlagList(nil); got != "" {
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
}
names := make([]string, inlineFlagListLimit+5)
for i := range names {
names[i] = fmt.Sprintf("flag-%02d", i)
}
got := inlineFlagList(names)
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
}
if strings.Contains(got, names[inlineFlagListLimit]) {
t.Errorf("list should stop at the limit, got %q", got)
}
}
func TestCanonicalEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -278,19 +161,4 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
}
})
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}

View File

@@ -197,7 +197,7 @@ func TestSheetHelpersValidationMetadata(t *testing.T) {
// api call's body. The dry-run output format is:
//
// === Dry Run ===
// { "api": [{...}], ... }
// { "ok": true, "dry_run": true, "data": { "api": [{...}], ... } }
//
// Tests use this to assert the One-OpenAPI wire body is constructed
// correctly without exercising the real endpoint.
@@ -220,7 +220,7 @@ func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, _ := dryRun["api"].([]interface{})
calls, _ := dryRunAPIEntries(dryRun)
return calls
}
@@ -240,7 +240,7 @@ func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} {
t.Helper()
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRun["api"].([]interface{})
calls, ok := dryRunAPIEntries(dryRun)
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}
@@ -252,6 +252,15 @@ func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} {
return body
}
func dryRunAPIEntries(dryRun map[string]interface{}) ([]interface{}, bool) {
if data, ok := dryRun["data"].(map[string]interface{}); ok {
calls, ok := data["api"].([]interface{})
return calls, ok
}
calls, ok := dryRun["api"].([]interface{})
return calls, ok
}
// decodeToolInput parses the JSON-string `input` field embedded in a
// dry-run body whose tool_name matches `expected`. Returns the decoded
// tool input map so tests can assert on specific input fields.

View File

@@ -158,7 +158,7 @@ func dryRunFirstCallURL(t *testing.T, sc common.Shortcut, args []string) string
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRun["api"].([]interface{})
calls, ok := dryRunAPIEntries(dryRun)
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}

View File

@@ -270,10 +270,11 @@ func TestReplacePagesDryRunPlansOnly(t *testing.T) {
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
}
if out["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
data, _ := out["data"].(map[string]interface{})
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
plan, _ := out["plan"].([]interface{})
plan, _ := data["plan"].([]interface{})
if len(plan) != 1 {
t.Fatalf("plan len = %d, want 1", len(plan))
}
@@ -281,7 +282,7 @@ func TestReplacePagesDryRunPlansOnly(t *testing.T) {
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
t.Fatalf("plan item = %#v", item)
}
api, _ := out["api"].([]interface{})
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("api len = %d, want create/delete plan", len(api))
}

View File

@@ -508,7 +508,8 @@ func TestUploadAttachmentTask_DryRun(t *testing.T) {
if err := json.Unmarshal([]byte(out), &dry); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out)
}
calls, _ := dry["api"].([]interface{})
data, _ := dry["data"].(map[string]interface{})
calls, _ := data["api"].([]interface{})
if len(calls) != 1 {
t.Fatalf("expected 1 api call in dry-run, got %d: %v", len(calls), calls)
}

View File

@@ -47,8 +47,8 @@ func TestSlashCommandList_DryRunShowsGetPath(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "GET", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandCreate_DryRunShowsPostBody pins the POST body shape for
@@ -77,14 +77,14 @@ func TestSlashCommandCreate_DryRunShowsPostBody(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "greet", gjson.Get(out, "api.0.body.command").String(), "stdout:\n%s", out)
assert.Equal(t, "say hi", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
assert.Equal(t, "你好", gjson.Get(out, "api.0.body.description.i18n.zh_cn").String(), "stdout:\n%s", out)
assert.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "greet", clie2e.DryRunGet(out, "api.0.body.command").String(), "stdout:\n%s", out)
assert.Equal(t, "say hi", clie2e.DryRunGet(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
assert.Equal(t, "你好", clie2e.DryRunGet(out, "api.0.body.description.i18n.zh_cn").String(), "stdout:\n%s", out)
// icon is a top-level key, sibling of description.
assert.Equal(t, "skill_outlined", gjson.Get(out, "api.0.body.icon.icon_key").String(), "stdout:\n%s", out)
assert.False(t, gjson.Get(out, "api.0.body.description.icon").Exists(), "icon must not be nested inside description:\n%s", out)
assert.Equal(t, "skill_outlined", clie2e.DryRunGet(out, "api.0.body.icon.icon_key").String(), "stdout:\n%s", out)
assert.False(t, clie2e.DryRunGet(out, "api.0.body.description.icon").Exists(), "icon must not be nested inside description:\n%s", out)
}
// TestSlashCommandUpdate_DryRunShowsPatchPath pins the PATCH shape for
@@ -108,9 +108,9 @@ func TestSlashCommandUpdate_DryRunShowsPatchPath(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id%2Fwith%20space%3Fx", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "updated description", gjson.Get(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
assert.Equal(t, "PATCH", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id%2Fwith%20space%3Fx", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "updated description", clie2e.DryRunGet(out, "api.0.body.description.default_value").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_DryRunShowsDeletePath pins the DELETE shape for
@@ -137,8 +137,8 @@ func TestSlashCommandDelete_DryRunShowsDeletePath(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
assert.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id%2Fwith%20space%3Fx", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
assert.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
assert.Equal(t, slashCommandBasePath+"/id%2Fwith%20space%3Fx", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
}
// TestSlashCommandDelete_WithoutYesRequiresConfirmation asserts the

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsAccessScopeGetDryRun pins URL shape and --app-id requirement for the
@@ -35,11 +34,11 @@ func TestAppsAccessScopeGetDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
// GET request: no body and no query params.
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Exists())
})
t.Run("RejectsMissingAppID", func(t *testing.T) {

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsAccessScopeSetDryRun pins the user-facing scope-string -> server-enum
@@ -37,14 +36,14 @@ func TestAppsAccessScopeSetDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PUT", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Range", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.Equal(t, "ou_x", gjson.Get(result.Stdout, "api.0.body.users.0").String())
assert.Equal(t, "oc_x", gjson.Get(result.Stdout, "api.0.body.chats.0").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.departments").Exists(),
assert.Equal(t, "PUT", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "Range", clie2e.DryRunGet(result.Stdout, "api.0.body.scope").String())
assert.Equal(t, "ou_x", clie2e.DryRunGet(result.Stdout, "api.0.body.users.0").String())
assert.Equal(t, "oc_x", clie2e.DryRunGet(result.Stdout, "api.0.body.chats.0").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.departments").Exists(),
"empty department list must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("SpecificWithApplyConfig", func(t *testing.T) {
@@ -66,8 +65,8 @@ func TestAppsAccessScopeSetDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.True(t, gjson.Get(result.Stdout, "api.0.body.apply_config.enabled").Bool())
assert.Equal(t, "ou_y", gjson.Get(result.Stdout, "api.0.body.apply_config.approvers.0").String())
assert.True(t, clie2e.DryRunGet(result.Stdout, "api.0.body.apply_config.enabled").Bool())
assert.Equal(t, "ou_y", clie2e.DryRunGet(result.Stdout, "api.0.body.apply_config.approvers.0").String())
})
t.Run("PublicMapsToAll", func(t *testing.T) {
@@ -87,10 +86,10 @@ func TestAppsAccessScopeSetDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "All", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Bool())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
assert.Equal(t, "All", clie2e.DryRunGet(result.Stdout, "api.0.body.scope").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.require_login").Bool())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.users").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("TenantMapsToTenant", func(t *testing.T) {
@@ -109,10 +108,10 @@ func TestAppsAccessScopeSetDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Tenant", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.Equal(t, "Tenant", clie2e.DryRunGet(result.Stdout, "api.0.body.scope").String())
// scope is the only body field in tenant mode.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.require_login").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.users").Exists())
})
t.Run("RejectsSpecificMissingTargets", func(t *testing.T) {

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsCreateDryRun pins the request shape and Validate behavior for
@@ -36,13 +35,13 @@ func TestAppsCreateDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "Demo", clie2e.DryRunGet(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", clie2e.DryRunGet(result.Stdout, "api.0.body.app_type").String())
// Optional fields stay omitted when not provided.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.description").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.icon_url").Exists())
})
t.Run("AllFields", func(t *testing.T) {
@@ -63,10 +62,10 @@ func TestAppsCreateDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String())
assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String())
assert.Equal(t, "Demo", clie2e.DryRunGet(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", clie2e.DryRunGet(result.Stdout, "api.0.body.app_type").String())
assert.Equal(t, "survey app", clie2e.DryRunGet(result.Stdout, "api.0.body.description").String())
assert.Equal(t, "https://example.com/icon.svg", clie2e.DryRunGet(result.Stdout, "api.0.body.icon_url").String())
})
t.Run("RejectsMissingName", func(t *testing.T) {

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBEnvCreateDryRun pins +db-env-create URL `/apps/{app_id}/db_dev_init` 和 sync_data body 透传。
@@ -30,9 +29,9 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/db_dev_init", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/db_dev_init", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "false", clie2e.DryRunGet(result.Stdout, "api.0.body.sync_data").String())
})
t.Run("SyncDataTrue", func(t *testing.T) {
@@ -45,6 +44,6 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "true", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
assert.Equal(t, "true", clie2e.DryRunGet(result.Stdout, "api.0.body.sync_data").String())
})
}

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URLCLI 永远走 DBA 模式
@@ -30,14 +29,14 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/sql_commands", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "SELECT 1", gjson.Get(result.Stdout, "api.0.body.sql").String())
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.params.transactional").String(),
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/sql_commands", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "SELECT 1", clie2e.DryRunGet(result.Stdout, "api.0.body.sql").String())
assert.Equal(t, "false", clie2e.DryRunGet(result.Stdout, "api.0.params.transactional").String(),
"CLI is DBA mode → must send transactional=false in query")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.transactional").Exists(),
"transactional should be in query, not body")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
})
@@ -51,7 +50,7 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "online", clie2e.DryRunGet(result.Stdout, "api.0.params.env").String())
})
t.Run("RejectsEmptySQL", func(t *testing.T) {

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBTableGetDryRun pins +db-table-get 复用存量 URL。
@@ -33,9 +32,9 @@ func TestAppsDBTableGetDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables/orders", gjson.Get(result.Stdout, "api.0.url").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists(),
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables/orders", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.format").Exists(),
"default (json) should omit format query")
})
@@ -65,7 +64,7 @@ func TestAppsDBTableGetDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.format").Exists())
})
t.Run("RequiresTableFlag", func(t *testing.T) {

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBTableListDryRun pins +db-table-list 复用存量 URL/apps/{app_id}/tables
@@ -30,14 +29,14 @@ func TestAppsDBTableListDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
assert.Equal(t, "20", clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_stats").Exists(),
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.include_stats").Exists(),
"CLI should not send include_stats query (server returns stats by default)")
})
@@ -55,9 +54,9 @@ func TestAppsDBTableListDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "cursor-abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
assert.Equal(t, "dev", clie2e.DryRunGet(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "50", clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "cursor-abc", clie2e.DryRunGet(result.Stdout, "api.0.params.page_token").String())
})
t.Run("RejectsBlankAppID", func(t *testing.T) {

View File

@@ -12,7 +12,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsEnvPullDryRun(t *testing.T) {
@@ -33,14 +32,14 @@ func TestAppsEnvPullDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.include_values").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
assert.True(t, gjson.Get(result.Stdout, "project_path").Exists())
assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local")
assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists())
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "dev", clie2e.DryRunGet(result.Stdout, "api.0.body.env").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.include_values").Exists())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Exists())
assert.True(t, clie2e.DryRunGet(result.Stdout, "project_path").Exists())
assert.Contains(t, clie2e.DryRunGet(result.Stdout, "env_file").String(), ".env.local")
assert.False(t, clie2e.DryRunGet(result.Stdout, "env_keys").Exists())
})
t.Run("CustomProjectPath", func(t *testing.T) {
@@ -60,8 +59,8 @@ func TestAppsEnvPullDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, projectDir, gjson.Get(result.Stdout, "project_path").String())
assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String())
assert.Equal(t, projectDir, clie2e.DryRunGet(result.Stdout, "project_path").String())
assert.Equal(t, filepath.Join(projectDir, ".env.local"), clie2e.DryRunGet(result.Stdout, "env_file").String())
})
t.Run("MissingAppID", func(t *testing.T) {

View File

@@ -13,7 +13,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsGitCredentialInitDryRun(t *testing.T) {
@@ -34,17 +33,17 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.Equal(t, "api-plus-local-setup", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "initialize_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "local_effects.#").Int())
assert.Equal(t, "save the issued PAT in the local system credential store", gjson.Get(result.Stdout, "local_effects.0").String())
assert.Equal(t, "write app-scoped git credential metadata", gjson.Get(result.Stdout, "local_effects.1").String())
assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", gjson.Get(result.Stdout, "local_effects.2").String())
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "app_xxx", clie2e.DryRunGet(result.Stdout, "api.0.params.app_id").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Exists())
assert.Equal(t, "api-plus-local-setup", clie2e.DryRunGet(result.Stdout, "mode").String())
assert.Equal(t, "initialize_local_git_credential", clie2e.DryRunGet(result.Stdout, "action").String())
assert.True(t, strings.HasSuffix(clie2e.DryRunGet(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, int64(3), clie2e.DryRunGet(result.Stdout, "local_effects.#").Int())
assert.Equal(t, "save the issued PAT in the local system credential store", clie2e.DryRunGet(result.Stdout, "local_effects.0").String())
assert.Equal(t, "write app-scoped git credential metadata", clie2e.DryRunGet(result.Stdout, "local_effects.1").String())
assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", clie2e.DryRunGet(result.Stdout, "local_effects.2").String())
}
func TestAppsGitCredentialListDryRun(t *testing.T) {
@@ -61,12 +60,12 @@ func TestAppsGitCredentialListDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-read-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "list_local_git_credentials", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.Contains(t, gjson.Get(result.Stdout, "storage_root").String(), filepath.Join("", "spark"))
assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", gjson.Get(result.Stdout, "reads.0").String())
assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", clie2e.DryRunGet(result.Stdout, "description").String())
assert.Equal(t, "local-read-only", clie2e.DryRunGet(result.Stdout, "mode").String())
assert.Equal(t, "list_local_git_credentials", clie2e.DryRunGet(result.Stdout, "action").String())
assert.Equal(t, int64(0), clie2e.DryRunGet(result.Stdout, "api.#").Int())
assert.Contains(t, clie2e.DryRunGet(result.Stdout, "storage_root").String(), filepath.Join("", "spark"))
assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", clie2e.DryRunGet(result.Stdout, "reads.0").String())
}
func TestAppsGitCredentialRemoveDryRun(t *testing.T) {
@@ -83,11 +82,11 @@ func TestAppsGitCredentialRemoveDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-cleanup-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "remove_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "app_id").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, "read app-scoped git credential metadata", gjson.Get(result.Stdout, "effects.0").String())
assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", clie2e.DryRunGet(result.Stdout, "description").String())
assert.Equal(t, "local-cleanup-only", clie2e.DryRunGet(result.Stdout, "mode").String())
assert.Equal(t, "remove_local_git_credential", clie2e.DryRunGet(result.Stdout, "action").String())
assert.Equal(t, "app_xxx", clie2e.DryRunGet(result.Stdout, "app_id").String())
assert.Equal(t, int64(0), clie2e.DryRunGet(result.Stdout, "api.#").Int())
assert.True(t, strings.HasSuffix(clie2e.DryRunGet(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, "read app-scoped git credential metadata", clie2e.DryRunGet(result.Stdout, "effects.0").String())
}

View File

@@ -13,7 +13,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsHTMLPublishDryRun exercises the walker / manifest layer without
@@ -50,13 +49,13 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
// file_count / files / total_size_bytes sit at envelope top level
// (not under api.0.body — manifest is dry-run metadata, not the HTTP body).
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int())
assert.Greater(t, gjson.Get(result.Stdout, "total_size_bytes").Int(), int64(0))
files := gjson.Get(result.Stdout, "files").Array()
assert.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "file_count").Int())
assert.Greater(t, clie2e.DryRunGet(result.Stdout, "total_size_bytes").Int(), int64(0))
files := clie2e.DryRunGet(result.Stdout, "files").Array()
require.Len(t, files, 2)
names := []string{files[0].String(), files[1].String()}
assert.Contains(t, names, "index.html")
@@ -83,8 +82,8 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
assert.Equal(t, int64(1), clie2e.DryRunGet(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", clie2e.DryRunGet(result.Stdout, "files.0").String())
})
t.Run("HiddenFilesIncludedExceptGit", func(t *testing.T) {
@@ -115,9 +114,9 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
// index.html + .DS_Store kept; .git/HEAD filtered out → 2 files.
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int(),
assert.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "file_count").Int(),
"walker must keep non-.git hidden files but drop .git; got: %s", result.Stdout)
names := gjson.Get(result.Stdout, "files").Array()
names := clie2e.DryRunGet(result.Stdout, "files").Array()
var got []string
for _, n := range names {
got = append(got, n.String())
@@ -145,9 +144,9 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "total_size_bytes").Int())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html",
assert.Equal(t, int64(0), clie2e.DryRunGet(result.Stdout, "file_count").Int())
assert.Equal(t, int64(0), clie2e.DryRunGet(result.Stdout, "total_size_bytes").Int())
assert.Contains(t, clie2e.DryRunGet(result.Stdout, "validation_error").String(), "index.html",
"empty dir should report index.html validation_error: %s", result.Stdout)
})
@@ -171,9 +170,9 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html")
assert.Equal(t, int64(1), clie2e.DryRunGet(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", clie2e.DryRunGet(result.Stdout, "files.0").String())
assert.Contains(t, clie2e.DryRunGet(result.Stdout, "validation_error").String(), "index.html")
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
@@ -269,7 +268,7 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
waived := gjson.Get(result.Stdout, "sensitive_waived").Array()
waived := clie2e.DryRunGet(result.Stdout, "sensitive_waived").Array()
require.Len(t, waived, 1, "expected sensitive_waived to list the file, got: %s", result.Stdout)
assert.Equal(t, ".env.example", waived[0].String())
})
@@ -295,7 +294,7 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, int64(1), clie2e.DryRunGet(result.Stdout, "file_count").Int())
})
t.Run("TrimsAppIDAndPath", func(t *testing.T) {
@@ -319,8 +318,8 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int(),
clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, int64(1), clie2e.DryRunGet(result.Stdout, "file_count").Int(),
"path trimming must produce the same manifest as untrimmed input")
})
}

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsListDryRun pins cursor-pagination params: default page_size=20 is
@@ -31,10 +30,10 @@ func TestAppsListDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "20", clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
})
@@ -48,7 +47,7 @@ func TestAppsListDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "50", clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithPageToken", func(t *testing.T) {
@@ -61,8 +60,8 @@ func TestAppsListDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "cursor_abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "cursor_abc", clie2e.DryRunGet(result.Stdout, "api.0.params.page_token").String())
assert.Equal(t, "20", clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithKeywordOwnershipAppType", func(t *testing.T) {
@@ -77,9 +76,9 @@ func TestAppsListDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "survey", gjson.Get(result.Stdout, "api.0.params.keyword").String())
assert.Equal(t, "mine", gjson.Get(result.Stdout, "api.0.params.ownership").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.params.app_type").String())
assert.Equal(t, "survey", clie2e.DryRunGet(result.Stdout, "api.0.params.keyword").String())
assert.Equal(t, "mine", clie2e.DryRunGet(result.Stdout, "api.0.params.ownership").String())
assert.Equal(t, "html", clie2e.DryRunGet(result.Stdout, "api.0.params.app_type").String())
})
t.Run("OmitsEmptyFilters", func(t *testing.T) {
@@ -93,7 +92,7 @@ func TestAppsListDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, p := range []string{"keyword", "ownership", "app_type"} {
assert.False(t, gjson.Get(result.Stdout, "api.0.params."+p).Exists(),
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params."+p).Exists(),
"empty %s must be omitted", p)
}
})
@@ -134,6 +133,6 @@ func TestAppsListDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "-1", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "-1", clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").String())
})
}

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsUpdateDryRun pins partial-update semantics: PATCH with only the
@@ -35,10 +34,10 @@ func TestAppsUpdateDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PATCH", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists(),
assert.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "v2", clie2e.DryRunGet(result.Stdout, "api.0.body.name").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.description").Exists(),
"description must be omitted when not provided")
})
@@ -59,8 +58,8 @@ func TestAppsUpdateDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "updated", gjson.Get(result.Stdout, "api.0.body.description").String())
assert.Equal(t, "v2", clie2e.DryRunGet(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "updated", clie2e.DryRunGet(result.Stdout, "api.0.body.description").String())
})
t.Run("RejectsMissingAppID", func(t *testing.T) {

View File

@@ -12,7 +12,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBase_AttachmentDryRun(t *testing.T) {
@@ -42,10 +41,10 @@ func TestBase_AttachmentDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "<uploaded_file_token>", gjson.Get(out, "api.2.body.attachments.rec_x.fld_att.0.file_token").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", clie2e.DryRunGet(out, "api.1.url").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments", clie2e.DryRunGet(out, "api.2.url").String(), out)
require.Equal(t, "<uploaded_file_token>", clie2e.DryRunGet(out, "api.2.body.attachments.rec_x.fld_att.0.file_token").String(), out)
})
t.Run("download", func(t *testing.T) {
@@ -65,9 +64,9 @@ func TestBase_AttachmentDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "<extra_info_if_present>", gjson.Get(out, "api.1.params.extra").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", clie2e.DryRunGet(out, "api.1.url").String(), out)
require.Equal(t, "<extra_info_if_present>", clie2e.DryRunGet(out, "api.1.params.extra").String(), out)
})
t.Run("download all", func(t *testing.T) {
@@ -86,8 +85,8 @@ func TestBase_AttachmentDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", clie2e.DryRunGet(out, "api.1.url").String(), out)
})
t.Run("remove", func(t *testing.T) {
@@ -107,8 +106,8 @@ func TestBase_AttachmentDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "box_a", gjson.Get(out, "api.0.body.attachments.rec_x.fld_att.0.file_token").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "box_a", clie2e.DryRunGet(out, "api.0.body.attachments.rec_x.fld_att.0.file_token").String(), out)
})
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseBlockDryRun(t *testing.T) {
@@ -31,9 +30,9 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.False(t, gjson.Get(out, "api.0.body.parent_id").Exists(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.parent_id").Exists(), out)
})
t.Run("list folder", func(t *testing.T) {
@@ -50,9 +49,9 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out)
require.False(t, gjson.Get(out, "api.0.body.type").Exists(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "blk_folder", clie2e.DryRunGet(out, "api.0.body.parent_id").String(), out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.type").Exists(), out)
})
t.Run("create", func(t *testing.T) {
@@ -70,11 +69,11 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "docx", gjson.Get(out, "api.0.body.type").String(), out)
require.Equal(t, "Spec", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "docx", clie2e.DryRunGet(out, "api.0.body.type").String(), out)
require.Equal(t, "Spec", clie2e.DryRunGet(out, "api.0.body.name").String(), out)
require.Equal(t, "blk_folder", clie2e.DryRunGet(out, "api.0.body.parent_id").String(), out)
})
t.Run("move root", func(t *testing.T) {
@@ -90,10 +89,10 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.True(t, gjson.Get(out, "api.0.body.parent_id").Exists(), out)
require.Equal(t, "Null", gjson.Get(out, "api.0.body.parent_id").Type.String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.parent_id").Exists(), out)
require.Equal(t, "Null", clie2e.DryRunGet(out, "api.0.body.parent_id").Type.String(), out)
})
t.Run("move after", func(t *testing.T) {
@@ -111,9 +110,9 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out)
require.Equal(t, "blk_b", gjson.Get(out, "api.0.body.after_id").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "blk_folder", clie2e.DryRunGet(out, "api.0.body.parent_id").String(), out)
require.Equal(t, "blk_b", clie2e.DryRunGet(out, "api.0.body.after_id").String(), out)
})
t.Run("rename", func(t *testing.T) {
@@ -130,9 +129,9 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/rename", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "Renamed", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/rename", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "Renamed", clie2e.DryRunGet(out, "api.0.body.name").String(), out)
})
t.Run("delete", func(t *testing.T) {
@@ -148,7 +147,7 @@ func TestBaseBlockDryRun(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), out)
})
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseCreateDryRun(t *testing.T) {
@@ -34,24 +33,24 @@ func TestBaseCreateDryRun(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "Project Tracker", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "Asia/Shanghai", gjson.Get(out, "api.0.body.time_zone").String(), out)
require.Equal(t, "/open-apis/base/v3/bases", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "Project Tracker", clie2e.DryRunGet(out, "api.0.body.name").String(), out)
require.Equal(t, "Asia/Shanghai", clie2e.DryRunGet(out, "api.0.body.time_zone").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "GET", gjson.Get(out, "api.1.method").String(), out)
require.Equal(t, int64(0), gjson.Get(out, "api.1.params.offset").Int(), out)
require.Equal(t, int64(100), gjson.Get(out, "api.1.params.limit").Int(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", clie2e.DryRunGet(out, "api.1.url").String(), out)
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.1.method").String(), out)
require.Equal(t, int64(0), clie2e.DryRunGet(out, "api.1.params.offset").Int(), out)
require.Equal(t, int64(100), clie2e.DryRunGet(out, "api.1.params.limit").Int(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), out)
require.Equal(t, "Tasks", gjson.Get(out, "api.2.body.name").String(), out)
require.Equal(t, "Title", gjson.Get(out, "api.2.body.fields.0.name").String(), out)
require.Equal(t, "Status", gjson.Get(out, "api.2.body.fields.1.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", clie2e.DryRunGet(out, "api.2.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.2.method").String(), out)
require.Equal(t, "Tasks", clie2e.DryRunGet(out, "api.2.body.name").String(), out)
require.Equal(t, "Title", clie2e.DryRunGet(out, "api.2.body.fields.0.name").String(), out)
require.Equal(t, "Status", clie2e.DryRunGet(out, "api.2.body.fields.1.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", gjson.Get(out, "api.3.url").String(), out)
require.Equal(t, "DELETE", gjson.Get(out, "api.3.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", clie2e.DryRunGet(out, "api.3.url").String(), out)
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.3.method").String(), out)
}
func TestBaseCreateDryRunTableNameOnlyRenamesDefaultTable(t *testing.T) {
@@ -73,17 +72,17 @@ func TestBaseCreateDryRunTableNameOnlyRenamesDefaultTable(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "Project Tracker", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "Project Tracker", clie2e.DryRunGet(out, "api.0.body.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "GET", gjson.Get(out, "api.1.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", clie2e.DryRunGet(out, "api.1.url").String(), out)
require.Equal(t, "GET", clie2e.DryRunGet(out, "api.1.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "PATCH", gjson.Get(out, "api.2.method").String(), out)
require.Equal(t, "Tasks", gjson.Get(out, "api.2.body.name").String(), out)
require.False(t, gjson.Get(out, "api.3").Exists(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", clie2e.DryRunGet(out, "api.2.url").String(), out)
require.Equal(t, "PATCH", clie2e.DryRunGet(out, "api.2.method").String(), out)
require.Equal(t, "Tasks", clie2e.DryRunGet(out, "api.2.body.name").String(), out)
require.False(t, clie2e.DryRunGet(out, "api.3").Exists(), out)
}
func TestBaseCreateDryRunFieldsOnlyUsesDefaultTableName(t *testing.T) {
@@ -105,8 +104,8 @@ func TestBaseCreateDryRunFieldsOnlyUsesDefaultTableName(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), out)
require.Equal(t, "Table 1", gjson.Get(out, "api.2.body.name").String(), out)
require.Equal(t, "Title", gjson.Get(out, "api.2.body.fields.0.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", clie2e.DryRunGet(out, "api.2.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.2.method").String(), out)
require.Equal(t, "Table 1", clie2e.DryRunGet(out, "api.2.body.name").String(), out)
require.Equal(t, "Title", clie2e.DryRunGet(out, "api.2.body.fields.0.name").String(), out)
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseFieldCreateDryRunArrayCompat(t *testing.T) {
@@ -33,13 +32,13 @@ func TestBaseFieldCreateDryRunArrayCompat(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "A", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "text", gjson.Get(out, "api.0.body.type").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "A", clie2e.DryRunGet(out, "api.0.body.name").String(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.type").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), out)
require.Equal(t, "B", gjson.Get(out, "api.1.body.name").String(), out)
require.Equal(t, "text", gjson.Get(out, "api.1.body.type").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", clie2e.DryRunGet(out, "api.1.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.1.method").String(), out)
require.Equal(t, "B", clie2e.DryRunGet(out, "api.1.body.name").String(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api.1.body.type").String(), out)
}

View File

@@ -56,9 +56,9 @@ func TestBaseListDryRunAcceptsPageSizeAliasForLimit(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, int64(0), gjson.Get(result.Stdout, "api.0.params.offset").Int(), result.Stdout)
require.Equal(t, int64(40), gjson.Get(result.Stdout, "api.0.params.limit").Int(), result.Stdout)
require.False(t, gjson.Get(result.Stdout, "api.0.params.page_size").Exists(), result.Stdout)
require.Equal(t, int64(0), clie2e.DryRunGet(result.Stdout, "api.0.params.offset").Int(), result.Stdout)
require.Equal(t, int64(40), clie2e.DryRunGet(result.Stdout, "api.0.params.limit").Int(), result.Stdout)
require.False(t, clie2e.DryRunGet(result.Stdout, "api.0.params.page_size").Exists(), result.Stdout)
}
func TestBaseListDryRunRejectsLimitPageSizeConflict(t *testing.T) {

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCalendar_UpdateDryRun(t *testing.T) {
@@ -41,19 +40,19 @@ func TestCalendar_UpdateDryRun(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "updated dry-run", gjson.Get(out, "api.0.body.summary").String(), "stdout:\n%s", out)
require.False(t, gjson.Get(out, "api.0.body.need_notification").Bool(), "stdout:\n%s", out)
require.Equal(t, "PATCH", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "updated dry-run", clie2e.DryRunGet(out, "api.0.body.summary").String(), "stdout:\n%s", out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.need_notification").Bool(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees/batch_delete", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_old", gjson.Get(out, `api.1.body.delete_ids.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_oldroom", gjson.Get(out, `api.1.body.delete_ids.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees/batch_delete", clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_old", clie2e.DryRunGet(out, `api.1.body.delete_ids.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_oldroom", clie2e.DryRunGet(out, `api.1.body.delete_ids.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_new", gjson.Get(out, `api.2.body.attendees.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "oc_group", gjson.Get(out, `api.2.body.attendees.#(type=="chat").chat_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_newroom", gjson.Get(out, `api.2.body.attendees.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees", clie2e.DryRunGet(out, "api.2.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_new", clie2e.DryRunGet(out, `api.2.body.attendees.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "oc_group", clie2e.DryRunGet(out, `api.2.body.attendees.#(type=="chat").chat_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_newroom", clie2e.DryRunGet(out, `api.2.body.attendees.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
}

View File

@@ -75,6 +75,19 @@ func SkipWithoutUserToken(t *testing.T) {
}
}
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
func DryRunGet(stdout, path string) gjson.Result {
if path == "" {
return gjson.Get(stdout, "data")
}
return gjson.Get(stdout, "data."+path)
}
// DryRunData returns the dry-run payload for tests that assert legacy raw paths.
func DryRunData(stdout string) string {
return gjson.Get(stdout, "data").Raw
}
// Request describes one lark-cli invocation.
type Request struct {
// Args are required and exclude the lark-cli binary name.

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
@@ -32,13 +31,13 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/doxcnDryRunCompat/fetch" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/doxcnDryRunCompat/fetch" {
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "xml" {
if got := clie2e.DryRunGet(out, "api.0.body.format").String(); got != "xml" {
t.Fatalf("format=%q, want xml\nstdout:\n%s", got, out)
}
}
@@ -61,13 +60,13 @@ func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" {
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" {
if got := clie2e.DryRunGet(out, "api.0.body.read_option.read_mode").String(); got != "range" {
t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
if got := clie2e.DryRunGet(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out)
}
}
@@ -90,7 +89,7 @@ func TestDocsFetchDryRunUnsupportedSelectionAnchorFragmentStaysFull(t *testing.T
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.body.read_option").Raw; got != "" {
if got := clie2e.DryRunGet(out, "api.0.body.read_option").Raw; got != "" {
t.Fatalf("read_option=%s, want omitted for unsupported selection anchor\nstdout:\n%s", got, out)
}
}

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
@@ -164,7 +163,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if tt.wantURL != "" {
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, tt.wantURL, clie2e.DryRunGet(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
}
for key, want := range tt.wantParams {
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
@@ -173,11 +172,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
}
if tt.wantExtraParam != "" {
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
extraParam := clie2e.DryRunGet(result.Stdout, "api.0.body.extra_param").String()
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
}
if tt.wantRefLabel != "" {
got := gjson.Get(result.Stdout, "api.0.body.reference_map.widget.r1.label").String()
got := clie2e.DryRunGet(result.Stdout, "api.0.body.reference_map.widget.r1.label").String()
require.Equal(t, tt.wantRefLabel, got, "stdout:\n%s", result.Stdout)
}
})
@@ -187,7 +186,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
func assertDryRunField(t *testing.T, stdout, path string, want any) {
t.Helper()
got := gjson.Get(stdout, path)
got := clie2e.DryRunGet(stdout, path)
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
switch want := want.(type) {
case int:
@@ -222,7 +221,7 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/docs_ai/v1/documents", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "markdown", gjson.Get(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/docs_ai/v1/documents", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentDryRun_File(t *testing.T) {
@@ -32,23 +31,23 @@ func TestDriveAddCommentDryRun_File(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("data.api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
t.Fatalf("data.api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
t.Fatalf("data.api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" {
t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.1.body.file_type").String(); got != "file" {
t.Fatalf("data.api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
}
if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() {
t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
if !clie2e.DryRunGet(out, "api.1.body.anchor.block_id").Exists() {
t.Fatalf("data.api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" {
t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.1.body.anchor.block_id").String(); got != "test" {
t.Fatalf("data.api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
}
}
@@ -72,19 +71,19 @@ func TestDriveAddCommentDryRun_Base(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/baseDryRunComment/new_comments" {
t.Fatalf("api.0.url=%q, want new_comments\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/baseDryRunComment/new_comments" {
t.Fatalf("data.api.0.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_type").String(); got != "bitable" {
t.Fatalf("api.0.body.file_type=%q, want bitable\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.body.file_type").String(); got != "bitable" {
t.Fatalf("data.api.0.body.file_type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.block_id").String(); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("api.0.body.anchor.block_id=%q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.body.anchor.block_id").String(); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("data.api.0.body.anchor.block_id=%q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.base_record_id").String(); got != "recBIBgGmb" {
t.Fatalf("api.0.body.anchor.base_record_id=%q, want recBIBgGmb\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.body.anchor.base_record_id").String(); got != "recBIBgGmb" {
t.Fatalf("data.api.0.body.anchor.base_record_id=%q, want recBIBgGmb\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.base_view_id").String(); got != "vewc46MG1R" {
t.Fatalf("api.0.body.anchor.base_view_id=%q, want vewc46MG1R\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.body.anchor.base_view_id").String(); got != "vewc46MG1R" {
t.Fatalf("data.api.0.body.anchor.base_view_id=%q, want vewc46MG1R\nstdout:\n%s", got, out)
}
}

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_ApplyPermissionDryRun locks in the request shape the shortcut
@@ -127,20 +126,20 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) {
out := result.Stdout
// Dry-run output is the JSON envelope; gjson walks into api[0].
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
if got := clie2e.DryRunGet(out, "api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
if got := gjson.Get(out, "api.0.body.perm").String(); got != tt.wantPerm {
if got := clie2e.DryRunGet(out, "api.0.body.perm").String(); got != tt.wantPerm {
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
}
for k, v := range tt.wantBody {
if got := gjson.Get(out, "api.0.body."+k).String(); got != v {
if got := clie2e.DryRunGet(out, "api.0.body."+k).String(); got != v {
t.Fatalf("body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
@@ -148,7 +147,7 @@ func TestDrive_ApplyPermissionDryRun(t *testing.T) {
// remark field (the owner's request card would otherwise render
// a blank note).
if _, wantsRemark := tt.wantBody["remark"]; !wantsRemark {
if gjson.Get(out, "api.0.body.remark").Exists() {
if clie2e.DryRunGet(out, "api.0.body.remark").Exists() {
t.Fatalf("body.remark should be omitted when --remark is empty, stdout:\n%s", out)
}
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
@@ -35,28 +34,28 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "docxDryRunExport" {
if got := clie2e.DryRunGet(out, "api.0.body.token").String(); got != "docxDryRunExport" {
t.Fatalf("body.token=%q, want docxDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "docx" {
if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "docx" {
t.Fatalf("body.type=%q, want docx\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "pdf" {
if got := clie2e.DryRunGet(out, "api.0.body.file_extension").String(); got != "pdf" {
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.file_name").Exists() {
if clie2e.DryRunGet(out, "api.0.body.file_name").Exists() {
t.Fatalf("file_name should stay local metadata, not export_tasks body\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "custom-report.pdf" {
if got := clie2e.DryRunGet(out, "file_name").String(); got != "custom-report.pdf" {
t.Fatalf("file_name=%q, want custom-report.pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./exports" {
if got := clie2e.DryRunGet(out, "output_dir").String(); got != "./exports" {
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
}
}
@@ -82,31 +81,31 @@ func TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
if got := clie2e.DryRunGet(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "POST" {
t.Fatalf("api.1.method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("api.1.url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
if got := clie2e.DryRunGet(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
if got := clie2e.DryRunGet(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
if got := clie2e.DryRunGet(out, "wiki_token").String(); got != "wikiDryRunExport" {
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "file_name").String(); got != "wiki-report.pdf" {
if got := clie2e.DryRunGet(out, "file_name").String(); got != "wiki-report.pdf" {
t.Fatalf("file_name=%q, want wiki-report.pdf\nstdout:\n%s", got, out)
}
}
@@ -131,22 +130,22 @@ func TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask(t *testing.
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
if got := clie2e.DryRunGet(out, "api.0.params.token").String(); got != "wikiDryRunExport" {
t.Fatalf("api.0.params.token=%q, want wiki token\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
if got := clie2e.DryRunGet(out, "api.1.body.token").String(); got != "obj_token_from_step_0" {
t.Fatalf("api.1.body.token=%q, want resolved token placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
if got := clie2e.DryRunGet(out, "api.1.body.type").String(); got != "obj_type_from_step_0" {
t.Fatalf("api.1.body.type=%q, want resolved type placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "wiki_token").String(); got != "wikiDryRunExport" {
if got := clie2e.DryRunGet(out, "wiki_token").String(); got != "wikiDryRunExport" {
t.Fatalf("wiki_token=%q, want source wiki token\nstdout:\n%s", got, out)
}
}
@@ -173,22 +172,22 @@ func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
if got := clie2e.DryRunGet(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.extra_param").Exists() {
if clie2e.DryRunGet(out, "api.0.body.extra_param").Exists() {
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
if got := clie2e.DryRunGet(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
if got := clie2e.DryRunGet(out, "output_dir").String(); got != "./md-exports" {
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
}
}
@@ -214,22 +213,22 @@ func TestDriveExportDryRun_BitableBaseOnlySchema(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
if got := clie2e.DryRunGet(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
t.Fatalf("body.token=%q, want bitableDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "bitable" {
if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "bitable" {
t.Fatalf("body.type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "base" {
if got := clie2e.DryRunGet(out, "api.0.body.file_extension").String(); got != "base" {
t.Fatalf("body.file_extension=%q, want base\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.only_schema").Bool(); !got {
if got := clie2e.DryRunGet(out, "api.0.body.only_schema").Bool(); !got {
t.Fatalf("body.only_schema=%v, want true\nstdout:\n%s", got, out)
}
}

View File

@@ -11,7 +11,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
@@ -40,19 +39,19 @@ func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method = %q, want GET\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("data.api.0.method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("data.api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("data.api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("data.api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("data.api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// --- Happy path: all supported URL types ---
@@ -100,13 +99,13 @@ func TestDriveInspectDryRun_WikiURL(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "api.#").Int(),
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int(),
"expected exactly 2 dry-run API steps for wiki URL, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/wiki/v2/spaces/get_node",
gjson.Get(result.Stdout, "api.0.url").String(),
clie2e.DryRunGet(result.Stdout, "api.0.url").String(),
"expected get_node as first step, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
gjson.Get(result.Stdout, "api.1.url").String(),
clie2e.DryRunGet(result.Stdout, "api.1.url").String(),
"expected batch_query as second step, stdout:\n%s", result.Stdout)
}
@@ -239,10 +238,10 @@ func runInspectDryRun(t *testing.T, url string) *clie2e.Result {
func assertOneStepBatchQuery(t *testing.T, result *clie2e.Result) {
t.Helper()
require.Equal(t, int64(1), gjson.Get(result.Stdout, "api.#").Int(),
require.Equal(t, int64(1), clie2e.DryRunGet(result.Stdout, "api.#").Int(),
"expected exactly 1 dry-run API step, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
gjson.Get(result.Stdout, "api.0.url").String(),
clie2e.DryRunGet(result.Stdout, "api.0.url").String(),
"expected batch_query URL, stdout:\n%s", result.Stdout)
}

View File

@@ -10,7 +10,6 @@ import (
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) {
@@ -31,23 +30,23 @@ func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" {
t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" {
if got := clie2e.DryRunGet(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out)
}
isSolved := gjson.Get(out, "api.0.params.is_solved")
isSolved := clie2e.DryRunGet(out, "api.0.params.is_solved")
if !isSolved.Exists() || isSolved.Bool() {
t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
}
if gjson.Get(out, "api.0.params.is_whole").Exists() {
if clie2e.DryRunGet(out, "api.0.params.is_whole").Exists() {
t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
if got := clie2e.DryRunGet(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.params.user_id_type").Exists() {
if clie2e.DryRunGet(out, "api.0.params.user_id_type").Exists() {
t.Fatalf("api.0.params.user_id_type should be omitted\nstdout:\n%s", out)
}
}
@@ -75,26 +74,26 @@ func TestDriveListCommentsDryRun_WikiToken(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
if got := clie2e.DryRunGet(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
if got := clie2e.DryRunGet(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.1.params.is_solved").Exists() {
if clie2e.DryRunGet(out, "api.1.params.is_solved").Exists() {
t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out)
}
isWhole := gjson.Get(out, "api.1.params.is_whole")
isWhole := clie2e.DryRunGet(out, "api.1.params.is_whole")
if !isWhole.Exists() || isWhole.Bool() {
t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out)
}
if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
if got := clie2e.DryRunGet(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out)
}
}

View File

@@ -287,16 +287,16 @@ func TestDrive_MemberAddDryRun(t *testing.T) {
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantResourceType {
if got := clie2e.DryRunGet(out, "api.0.params.type").String(); got != tt.wantResourceType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out)
}
notification := gjson.Get(out, "api.0.params.need_notification")
notification := clie2e.DryRunGet(out, "api.0.params.need_notification")
if tt.wantNeedNotification == "" {
if notification.Exists() {
t.Fatalf("need_notification should be omitted\nstdout:\n%s", out)
@@ -304,10 +304,10 @@ func TestDrive_MemberAddDryRun(t *testing.T) {
} else if got := notification.String(); got != tt.wantNeedNotification {
t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out)
}
bodyPath := "api.0.body"
bodyPath := "data.api.0.body"
if tt.wantBatch {
bodyPath = "api.0.body.members.0"
if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 {
bodyPath = "data.api.0.body.members.0"
if count := len(clie2e.DryRunGet(out, "api.0.body.members").Array()); count != 2 {
t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out)
}
}

Some files were not shown because too many files have changed in this diff Show More