Files
larksuite__cli/shortcuts/common/runner_input_test.go
R0bynZhu 46e2186adf feat(slides): accept slide XML files in +create (#2197)
Assembling the --slides JSON array by hand is what callers keep getting
wrong. A page of SML is multi-line and quote-heavy, and shell has no
built-in way to JSON-escape it, so callers reached for `jq -n --rawfile`
to build the array. In environments without jq the substitution silently
became an empty string and the command ran on to create an empty deck,
or the half-escaped XML reached the backend and came back as an opaque
3350001 after the presentation already existed.

Two input forms remove the escaping step:

  --slides now declares Input{file, stdin}, so a finished array can be
  read with `--slides @deck.json` or piped in with `--slides -`.

  --slide is repeatable, takes one complete <slide> document (or @path),
  and the CLI assembles the array. Repetition order is page order.

The forms are mutually exclusive: merging them would make page order
depend on flag-parsing rules nobody wants to reason about.

Notes on the repeatable flag: the framework only resolves Flag.Input for
single-valued string flags, so --slide resolves @path itself, through
the same cmdutil.ReadInputFile the framework uses, keeping the
"relative path under the current directory" rule identical. It rejects
"-" outright, because a process has one stdin and that cannot mean "this
occurrence" on a repeatable flag; the error names both forms that work.

Structural validation now runs on the assembled array, so both forms
fail the same way, and it runs before the create call so a malformed
page can no longer leave an orphaned empty presentation behind.

Three inputs the first round of review found still slipping through are
now rejected or normalized before the create call. `--slides null` is
valid JSON for a slice, so it parsed without error and left the array
nil, which read as "no pages given" and produced the blank deck
reported as success that the empty-value check exists to prevent. An
`<?xml ...?>` prolog is well-formed XML, so the parser accepted it and
only the backend rejected it, with 4001000 buildSnNode, after the
presentation already existed; it is now caught in the shared slide
validator, which covers +add-slide and +replace-pages too. And a
leading UTF-8 BOM made `--slide @page.xml` reject a file that
`--slides @deck.json` accepted, because the framework strips it for
Input flags and the repeatable flag resolved @path itself;
StripUTF8BOM is exported so both paths normalize the same way.

Also threads the source flag name through uploadSlidesPlaceholders,
which previously reported +add-slide upload failures as --slides.

Docs: the create/troubleshooting references now teach the file inputs
instead of the jq array-building template, and the follow-up snippet
uses the CLI's own --jq instead of piping to an external jq.
The lint gate in SKILL.md now names `slides +create` as a whole rather
than only its --slide form.
2026-08-07 17:03:03 +08:00

335 lines
11 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/spf13/cobra"
)
// newTestRuntimeWithStdin creates a RuntimeContext with string flags and a fake stdin.
func newTestRuntimeWithStdin(flags map[string]string, stdin string) *RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range flags {
cmd.Flags().String(name, "", "")
}
cmd.ParseFlags(nil)
for name, val := range flags {
cmd.Flags().Set(name, val)
}
return &RuntimeContext{
Cmd: cmd,
Factory: &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(stdin),
},
},
}
}
func TestResolveInputFlags_DirectValue(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "hello world"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "hello world" {
t.Errorf("expected %q, got %q", "hello world", got)
}
if rctx.InputResolvedFromSource("markdown") {
t.Error("inline value must not be marked as resolved from a source")
}
}
func TestResolveInputFlags_Stdin(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "-"}, "content from stdin")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "content from stdin" {
t.Errorf("expected %q, got %q", "content from stdin", got)
}
if !rctx.InputResolvedFromSource("markdown") {
t.Error("stdin value should be marked as resolved from a source")
}
}
func TestResolveInputFlags_File(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
content := "## Hello\n\nThis is **markdown** from a file.\n"
if err := os.WriteFile("test.md", []byte(content), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@test.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != content {
t.Errorf("expected %q, got %q", content, got)
}
if !rctx.InputResolvedFromSource("markdown") {
t.Error("@file value should be marked as resolved from a source")
}
}
// TestResolveInputFlags_EscapedAtStaysInline pins that the @@ escape is
// inline content (a literal leading @), not an external source — heuristic
// guards keyed on InputResolvedFromSource must still see it.
func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@@handle"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "@handle" {
t.Errorf("expected %q, got %q", "@handle", got)
}
if rctx.InputResolvedFromSource("markdown") {
t.Error("escaped @@ value must not be marked as resolved from a source")
}
}
func TestResolveInputFlags_EmptyFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("empty.md", nil, 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@empty.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "" {
t.Errorf("expected empty string, got %q", got)
}
}
func TestResolveInputFlags_EmptyInput(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": ""}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestResolveInputFlags_NoInputSpec(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"token": "@something"}, "")
flags := []Flag{{Name: "token"}} // no Input
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// value should be unchanged — no resolution
if got := rctx.Str("token"); got != "@something" {
t.Errorf("expected %q, got %q", "@something", got)
}
}
func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"data": "-"}, "stdin data")
flags := []Flag{{Name: "data", Input: []string{File}}} // only file, no stdin
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for stdin not supported")
}
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support stdin") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
func TestResolveInputFlags_FileNotSupported(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"data": "@file.txt"}, "")
flags := []Flag{{Name: "data", Input: []string{Stdin}}} // only stdin, no file
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for file not supported")
}
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support file input") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
func TestResolveInputFlags_FileNotFound(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@nonexistent.md"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for missing file")
}
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "cannot read file") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@ "}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for empty file path")
}
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
func TestResolveInputFlags_EscapeAtSign(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"text": "@@mention someone"}, "")
flags := []Flag{{Name: "text", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("text"); got != "@mention someone" {
t.Errorf("expected %q, got %q", "@mention someone", got)
}
}
func TestResolveInputFlags_EscapeDoubleAt(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"text": "@@@triple"}, "")
flags := []Flag{{Name: "text", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// @@@ → strip first @, remaining is @@triple which is literal
if got := rctx.Str("text"); got != "@@triple" {
t.Errorf("expected %q, got %q", "@@triple", got)
}
}
func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"a": "-", "b": "-"}, "data")
flags := []Flag{
{Name: "a", Input: []string{Stdin}},
{Name: "b", Input: []string{Stdin}},
}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
vErr := assertValidationParam(t, err, "--b")
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
// The hint must steer an AI agent to the fix (@file for the extra flags),
// since `--a - <x --b - <y` is the exact misuse this guards against.
if !strings.Contains(vErr.Hint, "@file") {
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
}
}
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
// an @file path is rejected (typically an absolute /tmp path): flags that
// also accept stdin must explain the portable `--flag -` form — never cd'ing
// into the target directory or copying the file into the project tree.
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr := assertValidationParam(t, err, "--csv")
if !strings.Contains(vErr.Hint, "pipe the file contents") || !strings.Contains(vErr.Hint, "--csv -") {
t.Errorf("hint %q should explain the portable stdin form", vErr.Hint)
}
// A flag without stdin support must not get the stdin hint.
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr = assertValidationParam(t, err, "--file")
if strings.Contains(vErr.Hint, "stdin") {
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
}
}
func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"},
{"no BOM unchanged", "hello", "hello"},
{"empty unchanged", "", ""},
{"only BOM becomes empty", "\uFEFF", ""},
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
}
for _, c := range cases {
if got := StripUTF8BOM(c.in); got != c.want {
t.Errorf("%s: StripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
t.Errorf("leading BOM not stripped from stdin, got %q", got)
}
}
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
// with "invalid character 'ï'".
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
t.Errorf("leading BOM not stripped from file, got %q", got)
}
}