Files
larksuite__cli/shortcuts/sheets/helpers_test.go
xiongyuanwen-byted 835b52cc88 feat(sheets): cut the top command-error clusters from the 08-18..24 eval batch (#2559)
* feat(sheets): cut the top command-error clusters from the 08-18..24 eval batch

A trace analysis over 14,818 `lark-cli sheets` calls attributed 2,036 command
errors to 57 (subcommand, flag) groups, with the top 6 covering 76%. Four of
them are ours to fix; each is addressed at the layer that produced it.

--styles vocabulary (291 cases, the only group whose retry also failed):

  - Prescribe the border family as a whole. foldBorderFamilyAliases already
    absorbs border / borders / border_<side> / border_<attr>; what still
    reached the error path was the Lark OpenAPI's border_type (FULL_BORDER,
    OUTER_BORDER) and CSS's border_width — real vocabularies with no
    equivalent here, and border_type was the single top field in the group.
    Neither maps unambiguously onto a per-side style/weight/color triple, so
    they get one shared answer, not a silent alias.
  - Prescribe the OpenAPI's nested {range, style:{...}} envelope, plus
    bg_color / fill_color / text_color.
  - Match prescriptions on the key's letters alone, so border_type,
    borderType and border-type are one mistake, not three.
  - Collapse repeated issues in the --styles / --writes folds. One wrong
    field name in a payload styling N cells produced N identical issues, each
    re-listing the full supported vocabulary: the fold meant to save round
    trips was burying its own answer. The defect is now stated once and the
    other locations are named.

--sheets payload (419 cases):

  - Accept dtypes / formats as a positional array. The same pandas habit that
    produces `columns` and `data` produces df.dtypes.tolist(), and the
    payloads were otherwise correct. Only a 1:1 match with `columns` is
    accepted; a length mismatch is rejected rather than guessed.

+csv-put --file (35 cases):

  - Read the value as a path. --file is aliased onto --csv because agents
    reach for it, but the names promise different things — rewriting only the
    name left the path to be written into the sheet as literal text, which the
    file-path guard then rejected, with an error naming a flag the caller
    never typed. The read goes through the same cmdutil.ReadInputFile as
    @file, so the relative-path policy is unchanged and stdin stays the
    out-of-tree route. A value naming nothing readable still falls through to
    the guard, so --file holding literal CSV keeps working.

--help (88 cases of "required flag(s) ... not set"):

  - Mark required flags in the sheets help. MarkFlagRequired only sets a
    completion annotation cobra never renders, so a required flag read exactly
    like an optional one. Sourced from flag-defs, since +chart-create and
    +csv-put deliberately clear that annotation after mounting; a flag cobra
    has put in a one-required group is left unmarked, because neither member
    of such a pair is individually required.

The remaining big group (absolute paths passed to --file / @file, 636 cases)
is deliberately untouched: the cwd-relative policy is a protocol decision, and
that thread is being followed up separately.

* fix(sheets): correct --file alias provenance and mark its value resolved

Two defects in the +csv-put --file rule from the previous commit, both found
in review:

- The alias record was written from the flag-name normalizer, which pflag also
  runs for Lookup and Set — including once with the canonical name right after
  the rewrite, and again on every later lookup. `--file a.csv --csv ./b.csv`
  therefore still counted as "supplied by --file", and an explicit --csv path
  was silently read as a file instead of meeting its guard. The spelling is now
  staged by the normalizer and committed by the flag's Value, which runs once
  per real occurrence, so the last occurrence wins in either order.

- The rewritten value was not marked as read from a source, so a file whose
  contents are themselves path-shaped ("report.csv") failed csvPutInput's shape
  check — a valid CSV rejected as a caller who forgot the @. Marking it makes
  --file behave exactly like --csv @<path> all the way down, which also lets
  the guard skip it on its own rather than through a special case in Validate.

RuntimeContext gains an exported MarkInputResolved for the second half: the bit
already existed for @file / stdin, and a domain that resolves a source itself
needs to set it. No other domain calls it, so nothing else changes.

Also assert the typed contract (Param, Cause) rather than message text alone in
the style-prescription corpus and the collapsed-issue fold, per the repo's
error-test guideline.

* fix(sheets): answer every unreadable --file path under --file, and stage alias provenance only while parsing

Both from self-review of the branch.

An unreadable path passed as --file fell through to the --csv guard, which
answered naming a flag the caller never typed — and for a file that exists but
cannot be opened, prescribed "pass the same path with an @ prefix", which routes
through this very reader and fails identically. Only one case may fall through
now: a value that names nothing AND is not path-shaped, i.e. literal CSV text,
which --file accepted before this rule existed. A path-shaped value naming
nothing, an unreadable file, and a directory each answer under --file.

The alias spelling was staged with no Parsed() guard (the first commit had one;
flagalias.Bind still does). chainFlagAliases looks its aliases up while
installing and pflag normalizes on Lookup, so composing PostMount twice — which
installAliasProvenance explicitly anticipates — replayed "file" through the
already-installed normalizer at mount time, and the next real --csv occurrence
committed it: an explicit --csv path would then be read from disk instead of
meeting its guard. Verified the new regression test fails without the guard.

* fix(sheets): reset alias staging on remount, and assert cause on every --file read error

Second review pass, both valid.

FlagSet.Parsed() stays true once parsing has started, so the guard added in the
last commit only covers a remount that happens BEFORE the first parse. A remount
afterwards — its own alias lookups running through the normalizer the first pass
installed — could still leave a spelling staged for the next occurrence to
commit, which would read an explicit --csv path from disk. Re-running the
install now resets staging, closing the window from the other side. Verified the
new parse/remount/reparse test fails without the reset.

The unreadable-file and directory branches preserve the read error as Cause;
their tests now assert it, matching the sibling that already did.
2026-09-01 15:05:10 +08:00

455 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// testConfig returns a CliConfig wired with a stable user identity. Tests
// keep the AppID test-prefixed so logs / metrics can spot them.
func testConfig(t *testing.T) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-sheets-" + suffix,
AppSecret: "secret-sheets-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: "ou_test_user",
}
}
// newTestRig spins up a Factory wired with httpmock + the given shortcut
// mounted into a "sheets" parent command. Returns the cobra.Command ready
// to SetArgs / Execute, plus the stdout / stderr buffers and the registry.
func newTestRig(t *testing.T, sc common.Shortcut) (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
f, stdout, stderr, reg := cmdutil.TestFactory(t, testConfig(t))
parent := &cobra.Command{Use: "sheets"}
sc.Mount(parent, f)
parent.SilenceErrors = true
parent.SilenceUsage = true
return parent, stdout, stderr, reg
}
// runShortcut executes the shortcut with the given args and returns the
// captured stdout text. Mirrors the legacy package's parent.Execute()
// flow so test cases stay close to real CLI behavior.
func runShortcut(t *testing.T, sc common.Shortcut, args []string) (string, error) {
t.Helper()
parent, stdout, _, _ := newTestRig(t, sc)
parent.SetArgs(append([]string{sc.Command}, args...))
err := parent.Execute()
return stdout.String(), err
}
// runShortcutCapturingErr is runShortcut but also returns the stderr text
// so validation tests can inspect error envelopes.
func runShortcutCapturingErr(t *testing.T, sc common.Shortcut, args []string) (stdoutStr, stderrStr string, err error) {
t.Helper()
parent, stdout, stderr, _ := newTestRig(t, sc)
parent.SetArgs(append([]string{sc.Command}, args...))
err = parent.Execute()
return stdout.String(), stderr.String(), err
}
// runShortcutWithStubs is runShortcut + a slice of httpmock stubs.
// Stubs are registered before execute so the recorded API calls are
// served from the registry instead of touching the network.
func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs ...*httpmock.Stub) (string, error) {
t.Helper()
parent, stdout, _, reg := newTestRig(t, sc)
for _, s := range stubs {
reg.Register(s)
}
parent.SetArgs(append([]string{sc.Command}, args...))
err := parent.Execute()
return stdout.String(), err
}
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
//
// Replaces the older "strings.Contains(stdout+stderr+err.Error(), ...)" pattern
// across sheets tests: substring on a rendered envelope was brittle (any
// message tweak silently broke it) and didn't verify that the typed contract —
// category / subtype / cause preservation — held. Per coding guideline
// "Error-path tests must assert typed metadata via errs.ProblemOf
// (category / subtype / param) and cause preservation, not message substrings
// alone."
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for the most common case: a typed
// CategoryValidation error with SubtypeInvalidArgument. Returns the
// *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}
func TestSheetHelpersValidationMetadata(t *testing.T) {
t.Parallel()
t.Run("missing sheet selector reports both params", func(t *testing.T) {
t.Parallel()
err := requireSheetSelector("", "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two structured params", validationErr.Params)
}
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
}
// Eval traces recover on the very next call, so the missing piece is
// which name to pass — the hint has to name Sheet1 and the lookup.
for _, want := range []string{"Sheet1", "+workbook-info"} {
if !strings.Contains(validationErr.Hint, want) {
t.Errorf("hint should mention %q, got %q", want, validationErr.Hint)
}
}
})
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "sheets"}
cmd.Flags().String("url", "not-a-sheet-url", "")
cmd.Flags().String("spreadsheet-token", "", "")
_, err := resolveSpreadsheetToken(common.TestNewRuntimeContext(cmd, testConfig(t)))
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Param != "--url" {
t.Fatalf("param = %q, want --url", validationErr.Param)
}
})
t.Run("sheet selector control char keeps param and cause", func(t *testing.T) {
t.Parallel()
err := requireSheetSelector("bad\x00id", "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Param != "--sheet-id" {
t.Fatalf("param = %q, want --sheet-id", validationErr.Param)
}
if validationErr.Unwrap() == nil {
t.Fatalf("expected control-char validation cause to be preserved")
}
})
t.Run("invalid json flag keeps param and cause", func(t *testing.T) {
t.Parallel()
fv := newMapFlagViewForCommand("+cells-set", map[string]interface{}{"cells": "{"})
_, err := parseJSONFlag(fv, "cells")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Param != "--cells" {
t.Fatalf("param = %q, want --cells", validationErr.Param)
}
if validationErr.Unwrap() == nil {
t.Fatalf("expected JSON parse cause to be preserved")
}
})
}
// parseDryRunBody runs the shortcut in --dry-run and returns the first
// api call's body. The dry-run output format is:
//
// === Dry Run ===
// { "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.
func parseDryRunBody(t *testing.T, sc common.Shortcut, args []string) map[string]interface{} {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
return decodeDryRunFirstCall(t, out)
}
// parseDryRunAPI returns the full list of `api` entries from a dry-run
// output — used by shortcuts that emit multiple calls (e.g.
// +workbook-export, +cells-set-image, +cells-batch-set-style).
func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface{} {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
dryRun := decodeDryRunRaw(t, out)
calls, _ := dryRunAPIEntries(dryRun)
return calls
}
// dryRunWarning returns the advisory text a dry-run surfaces under
// data.warning_message, or "" when the shortcut emitted none.
func dryRunWarning(t *testing.T, sc common.Shortcut, args []string) string {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
data, _ := decodeDryRunRaw(t, out)["data"].(map[string]interface{})
warning, _ := data["warning_message"].(string)
return warning
}
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
t.Helper()
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("dry-run output has no JSON body:\n%s", out)
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(out[idx:]), &m); err != nil {
t.Fatalf("failed to parse dry-run JSON: %v\nraw=%s", err, out)
}
return m
}
func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} {
t.Helper()
dryRun := decodeDryRunRaw(t, out)
calls, ok := dryRunAPIEntries(dryRun)
if !ok || len(calls) == 0 {
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
}
call, _ := calls[0].(map[string]interface{})
body, _ := call["body"].(map[string]interface{})
if body == nil {
t.Fatalf("dry-run first call has no body: %#v", call)
}
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.
func decodeToolInput(t *testing.T, body map[string]interface{}, expectedToolName string) map[string]interface{} {
t.Helper()
if got, _ := body["tool_name"].(string); got != expectedToolName {
t.Fatalf("tool_name = %q, want %q", got, expectedToolName)
}
rawInput, _ := body["input"].(string)
if rawInput == "" {
t.Fatalf("body.input is empty: %#v", body)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(rawInput), &input); err != nil {
t.Fatalf("failed to parse tool input JSON: %v\nraw=%s", err, rawInput)
}
return input
}
// decodeEnvelopeData parses a successful envelope's data field — used by
// execute-path tests that go through the full callTool stack with stubs.
func decodeEnvelopeData(t *testing.T, out string) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
t.Fatalf("failed to decode envelope: %v\nraw=%s", err, out)
}
if ok, _ := envelope["ok"].(bool); !ok {
t.Fatalf("envelope.ok=false: %#v", envelope)
}
data, _ := envelope["data"].(map[string]interface{})
return data
}
// toolOutputStub builds an httpmock stub for the One-OpenAPI invoke_read
// or invoke_write endpoint. `outputJSON` is the JSON string the tool
// returns in data.output.
func toolOutputStub(token, kind string, outputJSON string) *httpmock.Stub {
suffix := "invoke_read"
if kind == "write" {
suffix = "invoke_write"
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + token + "/tools/" + suffix,
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"output": outputJSON,
},
},
}
}
// commonArgsURL is the typical --url and --sheet-id pair used by sheet-
// level tests.
const (
testToken = "shtcnTestTOK"
testURL = "https://example.feishu.cn/sheets/shtcnTestTOK"
testSheetID = "shtSubA"
testSheetID2 = "shtSubB"
)
// TestParseSpreadsheetRef locks the network-free classification of
// --url / --spreadsheet-token into a sheet token vs an (unresolved) wiki
// node_token. The wiki node is resolved later, at Execute time only.
func TestParseSpreadsheetRef(t *testing.T) {
t.Parallel()
mk := func(url, tok string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "sheets"}
cmd.Flags().String("url", url, "")
cmd.Flags().String("spreadsheet-token", tok, "")
return common.TestNewRuntimeContext(cmd, testConfig(t))
}
cases := []struct {
name string
url string
tok string
wantKind string
wantToken string
wantErr bool
}{
{name: "sheets url", url: "https://x.feishu.cn/sheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "spreadsheets url", url: "https://x.feishu.cn/spreadsheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "wiki url", url: "https://x.feishu.cn/wiki/wikDEF", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "wiki url with query", url: "https://x.feishu.cn/wiki/wikDEF?sheet=xxxxxx", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "raw token", tok: "shtRAW", wantKind: spreadsheetRefSheet, wantToken: "shtRAW"},
{name: "sheets url with /wiki/ in query stays sheet", url: "https://x.feishu.cn/sheets/shtABC?from=/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "sheets url with /wiki/ in fragment stays sheet", url: "https://x.feishu.cn/sheets/shtABC#/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "docx url unsupported", url: "https://x.feishu.cn/docx/docABC", wantErr: true},
{name: "neither provided", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, err := parseSpreadsheetRef(mk(tc.url, tc.tok))
if tc.wantErr {
if err == nil {
t.Fatalf("want error, got ref=%+v", ref)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref.Kind != tc.wantKind || ref.Token != tc.wantToken {
t.Fatalf("ref = %+v, want {Kind:%s Token:%s}", ref, tc.wantKind, tc.wantToken)
}
})
}
}
// TestCollapseAggregatedIssues covers the grouping rule behind the folded
// --styles / --writes messages: one defect repeated across items is stated
// once and its other locations named, while genuinely different defects each
// keep their own line.
func TestCollapseAggregatedIssues(t *testing.T) {
t.Parallel()
issue := func(msg string) error { return common.ValidationErrorf("%s", msg) }
t.Run("identical defects across items collapse", func(t *testing.T) {
t.Parallel()
got := collapseAggregatedIssues([]error{
issue("--styles.styles[0].cell_styles[0].border_type is bad; supported: a, b"),
issue("--styles.styles[0].cell_styles[1].border_type is bad; supported: a, b"),
})
if len(got) != 1 {
t.Fatalf("got %d lines, want 1: %q", len(got), got)
}
if !strings.Contains(got[0], "[same at 1 more: --styles.styles[0].cell_styles[1].border_type]") {
t.Errorf("the collapsed line must name the other location, got %q", got[0])
}
})
t.Run("different defects stay separate", func(t *testing.T) {
t.Parallel()
got := collapseAggregatedIssues([]error{
issue("--styles.styles[0].cell_styles[0].border_type is bad"),
issue("--styles.styles[0].cell_styles[1].bg_color is bad"),
})
if len(got) != 2 {
t.Fatalf("got %d lines, want 2: %q", len(got), got)
}
})
t.Run("a long repeat lists a few locations then counts the rest", func(t *testing.T) {
t.Parallel()
probs := make([]error, 0, 9)
for i := 0; i < 9; i++ {
probs = append(probs, issue(fmt.Sprintf("--styles.styles[0].cell_styles[%d].border_type is bad", i)))
}
got := collapseAggregatedIssues(probs)
if len(got) != 1 {
t.Fatalf("got %d lines, want 1: %q", len(got), got)
}
if !strings.Contains(got[0], "[same at 8 more:") || !strings.Contains(got[0], "+5 more]") {
t.Errorf("want 3 locations named and the rest counted, got %q", got[0])
}
})
t.Run("a lone issue is rendered verbatim", func(t *testing.T) {
t.Parallel()
got := collapseAggregatedIssues([]error{issue("--writes[0]: nope")})
if len(got) != 1 || got[0] != "--writes[0]: nope" {
t.Errorf("got %q, want the message unchanged", got)
}
})
}