Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions shortcuts/doc/docs_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,62 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
}
}

func TestDocsCreateV2WarnsOnPreWithoutCode(t *testing.T) {
t.Parallel()

f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})

err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>test</title><p>before</p><pre lang=\"text\">no code tag</pre><p>after</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

stderrStr := stderr.String()
if !strings.Contains(stderrStr, "missing a <code> child element") {
t.Fatalf("expected stderr warning about missing <code>, got:\n%s", stderrStr)
}
}

func TestDocsCreateV2NoWarningWhenPreHasCode(t *testing.T) {
t.Parallel()

f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})

err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>test</title><pre lang=\"go\"><code>func main() {}</code></pre>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

stderrStr := stderr.String()
if strings.Contains(stderrStr, "missing a <code> child element") {
t.Fatalf("did not expect warning for valid pre/code, got:\n%s", stderrStr)
}
}

// ── Helpers ──

func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
Expand Down
8 changes: 8 additions & 0 deletions shortcuts/doc/docs_create_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive")
}

// Warn about <pre> blocks missing <code> children (silently dropped by API).
if runtime.Str("doc-format") == "xml" && runtime.Factory != nil {
if warnings := validatePreTags(runtime.Str("content")); len(warnings) > 0 {
emitPreWarnings(runtime.IO().ErrOut, warnings)
}
}

return nil
}

Expand Down
150 changes: 150 additions & 0 deletions shortcuts/doc/docs_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
package doc

import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -133,3 +138,148 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
}
return common.TestNewRuntimeContext(cmd, nil)
}

// ── Integration tests (full pipeline with mocked HTTP) ──

func TestDocsUpdateV2WarnsOnPreWithoutCode(t *testing.T) {
t.Parallel()

f, stdout, stderr, reg := cmdutil.TestFactory(t, docsUpdateTestConfig(t))
registerDocsUpdateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"url": "https://example.feishu.cn/docx/doxcnUpdateDryRun",
},
"result": "success",
})

err := runDocsUpdateShortcut(t, f, stdout, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcnUpdateDryRun",
"--command", "block_insert_after",
"--block-id", "doxcnAnchor",
"--content", "<pre lang=\"text\">no code tag</pre>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

stderrStr := stderr.String()
if !strings.Contains(stderrStr, "missing a <code> child element") {
t.Fatalf("expected stderr warning about missing <code>, got:\n%s", stderrStr)
}
}

func TestDocsUpdateV2BlockInsertAfterHint(t *testing.T) {
t.Parallel()

f, stdout, _, reg := cmdutil.TestFactory(t, docsUpdateTestConfig(t))
registerDocsUpdateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"url": "https://example.feishu.cn/docx/doxcnUpdateDryRun",
},
"result": "success",
})

err := runDocsUpdateShortcut(t, f, stdout, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcnUpdateDryRun",
"--command", "block_insert_after",
"--block-id", "doxcnAnchor",
"--content", "<p>hello</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
hint, _ := data["_hint"].(string)
if hint == "" {
t.Fatalf("expected _hint in block_insert_after response, got: %#v", data)
}
if !strings.Contains(hint, "docs +fetch") {
t.Fatalf("_hint should mention docs +fetch, got: %s", hint)
}
}

func TestDocsUpdateV2AppendHint(t *testing.T) {
t.Parallel()

f, stdout, _, reg := cmdutil.TestFactory(t, docsUpdateTestConfig(t))
registerDocsUpdateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"url": "https://example.feishu.cn/docx/doxcnUpdateDryRun",
},
"result": "success",
})

err := runDocsUpdateShortcut(t, f, stdout, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcnUpdateDryRun",
"--command", "append",
"--content", "<p>hello</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
hint, _ := data["_hint"].(string)
if hint == "" {
t.Fatalf("expected _hint in append response, got: %#v", data)
}
}

// ── Helpers ──

func docsUpdateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()

replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-docs-update-" + suffix,
AppSecret: "secret-docs-update-" + suffix,
Brand: core.BrandFeishu,
}
}

func registerDocsUpdateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/docs_ai/v1/documents/doxcnUpdateDryRun",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}

func runDocsUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()

return mountAndRunDocs(t, DocsUpdate, args, f, stdout)
}
19 changes: 19 additions & 0 deletions shortcuts/doc/docs_update_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return common.FlagErrorf("--command append requires --content")
}
}

// Warn about <pre> blocks missing <code> children (silently dropped by API).
if runtime.Str("doc-format") == "xml" && content != "" && runtime.Factory != nil {
if warnings := validatePreTags(content); len(warnings) > 0 {
emitPreWarnings(runtime.IO().ErrOut, warnings)
}
}

return nil
}

Expand All @@ -132,6 +140,17 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}

// For block_insert_after and block_copy_insert_after, hint that users
// should fetch the document to discover newly created block IDs.
cmd := runtime.Str("command")
if (cmd == "block_insert_after" || cmd == "block_copy_insert_after" || cmd == "append") &&
common.GetString(data, "result") == "success" {
data["_hint"] = fmt.Sprintf(
"To discover newly inserted block IDs, fetch the document with: lark-cli docs +fetch --api-version v2 --doc %s --detail with-ids",
ref.Token,
)
}

runtime.OutRaw(data, nil)
return nil
}
Expand Down
49 changes: 49 additions & 0 deletions shortcuts/doc/xml_validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package doc

import (
"fmt"
"io"
"regexp"
)

// preTagRegex matches <pre ...>...</pre> blocks including those spanning multiple lines.
var preTagRegex = regexp.MustCompile(`(?s)<pre\b[^>]*>(.*?)</pre>`)

// codeTagRegex matches a <code> or <code ...> opening tag.
var codeTagRegex = regexp.MustCompile(`<code\b`)

// validatePreTags checks XML content for <pre> blocks that lack a <code> child
// element. The Lark Docs API silently drops such blocks, so we warn the user.
// Returns a list of warning messages, one per offending <pre> block.
func validatePreTags(content string) []string {
if content == "" {
return nil
}

matches := preTagRegex.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
return nil
}

var warnings []string
for i, m := range matches {
inner := m[1]
if !codeTagRegex.MatchString(inner) {
warnings = append(warnings, fmt.Sprintf(
"<pre> block #%d is missing a <code> child element; the Lark Docs API will silently drop this block. Wrap the content in <code>...</code> inside the <pre> tag.",
i+1,
))
}
}
return warnings
}

// emitPreWarnings writes pre-tag validation warnings to the given writer (typically stderr).
func emitPreWarnings(w io.Writer, warnings []string) {
for _, wm := range warnings {
fmt.Fprintf(w, "warning: %s\n", wm)
}
}
Loading
Loading