mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
3 Commits
codex/cli-
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15263efe30 | ||
|
|
5b67085b32 | ||
|
|
da149e66ba |
@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "XML str_replace rejects multiline pattern",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "block_delete without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects empty ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects duplicate ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
||||
|
||||
@@ -17,6 +17,46 @@ import (
|
||||
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestStripTopLevelXMLTitles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single title",
|
||||
content: "<title>Content title</title><p>body</p>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "multiple titles",
|
||||
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "nested title is preserved",
|
||||
content: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
want: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "malformed XML is preserved",
|
||||
content: "<title>Content title</title><p>A & B</p>",
|
||||
want: "<title>Content title</title><p>A & B</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
|
||||
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -16,7 +18,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" {
|
||||
content = stripTopLevelXMLTitles(content)
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
type docContentRange struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
// stripTopLevelXMLTitles preserves the established --title-wins contract while
|
||||
// avoiding duplicate-title warnings from XML content. If the fragment is not
|
||||
// well-formed XML, it is left untouched for the service to diagnose.
|
||||
func stripTopLevelXMLTitles(content string) string {
|
||||
const wrapperStart = "<root>"
|
||||
wrapped := wrapperStart + content + "</root>"
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
wrapperLen := int64(len(wrapperStart))
|
||||
depth := 0
|
||||
activeStart := int64(-1)
|
||||
ranges := make([]docContentRange, 0, 1)
|
||||
|
||||
for {
|
||||
tokenStart := decoder.InputOffset()
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
activeStart = tokenStart - wrapperLen
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
|
||||
activeStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ranges) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
cursor := int64(0)
|
||||
for _, item := range ranges {
|
||||
result.WriteString(content[int(cursor):int(item.start)])
|
||||
cursor = item.end
|
||||
}
|
||||
result.WriteString(content[int(cursor):])
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
|
||||
@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if pattern == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if err := validateBlockDeleteIDs(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
|
||||
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockDeleteIDs(raw string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
blockID := strings.TrimSpace(part)
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBlockDeleteIDs(raw string) string {
|
||||
parts := strings.Split(raw, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
if cmd == "block_delete" {
|
||||
blockID = normalizeBlockDeleteIDs(blockID)
|
||||
}
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
|
||||
@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"docs", "+update",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--command", "block_delete",
|
||||
"--block-id", "blkA,blkB,blkC",
|
||||
"--block-id", "blkA, blkB, blkC",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
|
||||
},
|
||||
{
|
||||
name: "history list",
|
||||
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "<title>Dry Run & Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--title", "Flag title",
|
||||
"--content", "<title>Content title</title><p>body</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
|
||||
}
|
||||
|
||||
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "multiline XML str_replace",
|
||||
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
|
||||
want: "must be inline",
|
||||
},
|
||||
{
|
||||
name: "duplicate block delete ID",
|
||||
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
|
||||
want: "duplicate ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user