Files
larksuite-cli/shortcuts/doc/doc_media_test.go
河伯 bc6590abef feat(doc): add --from-clipboard flag to docs +media-insert (#508)
* feat(doc): add --from-clipboard flag to docs +media-insert

Allow users to upload the current clipboard image directly to a Lark
document without saving to a local file first.

- New --from-clipboard bool flag (mutually exclusive with --file)
- shortcuts/doc/clipboard.go: readClipboardToTempFile() with per-OS impl
    macOS   — osascript (built-in, no extra deps)
    Windows — PowerShell + System.Windows.Forms (built-in)
    Linux   — tries xclip / wl-paste / xsel in order; clear install hint
              on failure
- No new Go dependencies, no Cgo
- Temp file is created before upload and removed via defer cleanup()
- --file changed from Required:true to optional; Validate enforces
  exactly-one of --file / --from-clipboard

* fix(doc): fix clipboard image read on macOS for screenshots and browser-copied images

- Add TIFF fallback (macOS screenshots default to TIFF, not PNG)
- Add HTML base64 fallback (images copied from Feishu/browser embed data URI)
- Use current directory for temp file so FileIO path validation passes

* fix(doc): scan HTML/RTF/text clipboard formats for base64 image data URIs

Extend attempt-3 fallback to iterate all text-based clipboard formats
(HTML, RTF, UTF-8, plain text) rather than only HTML.  Any format that
contains a "data:<mime>;base64,<data>" pattern is accepted, covering
images copied from Feishu, Chrome, Safari, and other apps that embed
base64 in non-HTML clipboard slots.  Also handle URL-safe base64.

* test(doc): add unit tests for clipboard helpers to meet 60% coverage threshold

Cover decodeHex, hexVal, decodeOsascriptData, reBase64DataURI, and
extractBase64ImageFromClipboard (via fake osascript on PATH).
Package coverage: 57% → 61.2%.

* fix(doc): address CodeRabbit review comments on clipboard feature

- Extend reBase64DataURI regex to cover URL-safe base64 chars (-_) so
  URL-safe payloads are matched before decoding is attempted
- Fix readClipboardLinux to continue to next tool when a found tool
  returns empty output instead of failing immediately
- Guard fake-osascript test with runtime.GOOS == "darwin" skip
- Use os.PathListSeparator instead of hardcoded ":" in test PATH setup

* fix(doc): replace os.* temp-file clipboard path with in-memory streaming

Fixes forbidigo lint violations in shortcuts/doc: os.CreateTemp, os.Remove,
os.Stat, os.WriteFile are banned in shortcuts/; replaced with vfs.* equivalents
for sips TIFF→PNG conversion, and eliminated temp files entirely elsewhere by
having platform clipboard readers return []byte directly.

- readClipboardDarwin: osascript outputs hex literals decoded in Go (no file I/O)
- readClipboardWindows: PowerShell outputs base64 to stdout, decoded in Go
- readClipboardLinux: tool stdout bytes returned directly
- convertTIFFToPNGViaSips: still needs temp files — uses vfs.CreateTemp/Remove
- DriveMediaUploadAllConfig/DriveMediaMultipartUploadConfig: add Content io.Reader
  field so in-memory clipboard bytes skip FileIO.Open() path
- Fix ineffassign in clipboard_test.go (scriptBody double-assignment)
- Update TestReadClipboardLinux_NoToolsReturnsError for new signature

* fix(doc): address CodeRabbit review comments on Linux clipboard path

- Update --from-clipboard flag description to list xclip, xsel and wl-paste
- Preserve last backend-specific error in readClipboardLinux so users see
  a meaningful message when a tool is found but fails
- Validate PNG magic bytes for xsel output (xsel cannot negotiate MIME types)
- Add URL-safe base64 regression test for reBase64DataURI

* fix(doc): strip whitespace from base64 payload before decoding clipboard data URI

HTML and RTF clipboard content often line-wraps base64 at 76 characters.
FindSubmatch returns the raw wrapped token so direct decode would fail.
Normalize whitespace with strings.Fields before passing to base64.Decode.

* fix(doc): drop TIFF fallback and internal/vfs import on macOS clipboard

depguard rule shortcuts-no-vfs forbids shortcuts/ from importing
internal/vfs directly. The only caller was the sips TIFF→PNG
conversion, which was already a fragile best-effort fallback that
required temp files.

Remove the TIFF fallback entirely; the remaining two attempts cover
the real-world cases:
  1. osascript → PNG hex literal — native screenshots and most apps
  2. scan text clipboard formats for base64 data URI — Feishu/browsers

* test(doc): cover readClipboardLinux xsel PNG validation and dispatcher path

Added tests:
- TestReadClipboardLinux_XselRejectsNonPNG: fake xsel that returns plain
  text is rejected by the PNG-magic check, preventing text from being
  uploaded as an "image".
- TestHasPNGMagic: table-driven coverage of the PNG signature check.
- TestReadClipboardImageBytes_UnsupportedPlatform: exercises the shared
  dispatcher post-processing and asserts the (nil, nil) invariant.

Raises clipboard.go diff coverage and brings the package from 61.6% to
63.8% overall.

* test: cover in-memory Content upload paths for clipboard feature

Adds unit tests for the new Content io.Reader branches introduced by
the clipboard feature:

- UploadDriveMediaAll with in-memory Content (drive_media_upload.go 87.5%)
- UploadDriveMediaMultipart with in-memory Content (84.6%)
- uploadDocMediaFile single-part and multipart with clipboard bytes
  (doc_media_upload.go 0% -> 88.9%)

Adds TestNewRuntimeContextForAPI helper that wires Factory, context,
and bot identity so package tests can invoke DoAPI without mounting
the full cobra command tree.

* test: cover clipboard Validate/DryRun branches and testing helper

Adds unit tests for the clipboard-related Validate/DryRun paths that
Codecov patch-coverage was flagging as uncovered:

- Validate error when neither --file nor --from-clipboard is supplied
- Validate error when both are supplied (mutual exclusion)
- DryRun output contains <clipboard image> placeholder
- Self-test for TestNewRuntimeContextForAPI so shortcuts/common
  sees coverage for the new helper (not just shortcuts/doc)

* test: cover Execute clipboard branch via injectable readClipboardImage

Makes readClipboardImageBytes swappable in tests by routing the call
through a package-level variable readClipboardImage. Tests inject a
synthetic PNG payload so the full Execute clipboard flow
(resolve → create block → upload in-memory bytes → bind) runs under
unit test without a real pasteboard.

Covers:
- TestDocMediaInsertExecuteFromClipboard: end-to-end happy path
- TestDocMediaInsertExecuteClipboardReadError: early-return on
  readClipboardImage() failure

* ci: re-trigger pull_request workflow for PR #508

Previous push to 9dedb7a did not trigger the main CI workflow via
the pull_request event (only PR Labels ran). The workflow_dispatch
run I triggered manually lacks PR-scoped secrets so security and
e2e-live failed. An empty commit replays the pull_request event so
the full matrix (deadcode, license-header, security, e2e-live) runs
with proper context.

* test(doc): guard info.Size() behind err check to prevent nil-deref

CodeRabbit flagged that 't.Fatalf("... size=%d err=%v", info.Size(), err)'
evaluates info.Size() even when os.Stat returned (nil, err), which nil-derefs.
Split the check into two stages so the error-path t.Fatalf does not touch
info.

* fix(doc): address fangshuyu-768 review on clipboard PR

Seven code changes driven by review feedback:

1. clipboard.go: stop using CombinedOutput() on osascript / powershell.
   Stdout is decoded, stderr is captured separately via cmd.Stderr and
   surfaced in the terminal error message, so locale warnings or
   AppleEvent permission prompts no longer pollute the hex/base64
   payload or mask the real failure.

2. clipboard.go: validate decoded base64 data URI bytes against known
   image magic headers (PNG/JPEG/GIF/WebP/BMP). A text clipboard that
   happens to contain a literal 'data:image/...;base64,...' fragment
   (documentation, tutorials, pasted HTML source) no longer silently
   becomes an image upload.

3. clipboard.go: simplify the Linux 'no tool found' install hint to a
   distro-agnostic phrasing instead of apt/yum only.

4. clipboard_test.go: delete the stale TestReadClipboardToTempFile_*
   tests. They referenced a readClipboardToTempFile function that no
   longer exists and only exercised os.CreateTemp/os.Remove. Replace
   with TestReadClipboardImageBytes_EmptyResultReturnsError which
   actually locks in the 'empty clipboard' → error contract of the
   current API (Linux-only since mac/Windows need a real pasteboard).

5. doc_media_upload.go: introduce UploadDocMediaFileConfig struct so
   uploadDocMediaFile takes a named config instead of 8 positional
   params. Drops the //nolint:lll the old call site had to carry.

6. doc_media_insert.go: convert the clipboard upload call to the new
   config struct and only set Config.Content when the clipboard branch
   actually produced bytes — this also fixes a latent typed-nil bug
   where a nil *bytes.Reader was being passed through an io.Reader
   parameter, which tripped the 'if cfg.Content != nil' check in
   UploadDriveMediaAll and crashed --file uploads.

7. shortcuts/common/testing.go: TestNewRuntimeContextForAPI now takes
   the identity as an explicit core.Identity parameter instead of
   hardcoding core.AsBot, and its self-test covers both AsBot and
   AsUser. Existing call sites pass core.AsBot explicitly.

Also annotates DryRun output with an 'upload_size_note' when
--from-clipboard is set, since DryRun never reads the pasteboard and
can't predict whether the payload will take the single-part or
multipart path.

* fix(doc): capture line-wrapped base64 in clipboard data URI regex (#586)

HTML and RTF clipboard content commonly folds base64 payloads at
76 chars (standard MIME folding). The previous character class
[A-Za-z0-9+/\-_]+=* stopped at the first \n, so the downstream
strings.Fields normalisation was a no-op (nothing to strip) and
extractBase64ImageFromClipboard silently uploaded a truncated
payload whose 8-byte prefix happened to pass hasKnownImageMagic.

Extend the class to include \s so the Fields strip actually has
whitespace to remove before base64 decoding. Terminators (", <,
), ;) remain outside the class so the match still ends at the
URI boundary.

Add TestReBase64DataURI_LineWrapped covering \n, \r\n, and \t
folds, full round-trip byte-equality, and the terminator-boundary
invariant so any future regression trips a failing test.

* docs(skill): add clipboard-empty fallback guidance for +media-insert

When --from-clipboard returns 'no image data' (empty clipboard, non-image
content, or Linux without xclip/wl-paste/xsel), the agent must NOT silently
swallow the error. It should tell the user the clipboard had no image, ask
for a local file path, then retry the same insert command with --file.

Lists three anti-patterns (silent success, guessing a file path, pre-emptive
save-then-file workaround) that agents have been tempted into.

* docs(skill): user-stated source trumps clipboard/file heuristic

The heuristic table (prefer --from-clipboard when image is on the
clipboard) is a fallback for when the user is vague. If the user
explicitly says 'use the screenshot I just copied' → clipboard; if
they give a path → --file. Agent must not silently swap sources even
when the other looks 'better'.

---------

Co-authored-by: fangshuyu-768 <shuyufang768@outlook.com>
2026-04-22 22:05:33 +08:00

886 lines
27 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func docsTestConfigWithAppID(appID string) *core.CliConfig {
return &core.CliConfig{
AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
}
}
func mountAndRunDocs(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "docs"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func withDocsWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir(%q) error: %v", dir, err)
}
t.Cleanup(func() {
if err := os.Chdir(cwd); err != nil {
t.Fatalf("restore cwd error: %v", err)
}
})
}
func TestDocMediaInsertRejectsOldDocURL(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "https://example.larksuite.com/doc/xxxxxx",
"--file", "dummy.png",
"--dry-run",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaInsertValidateRequiresFileOrClipboard(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "https://example.larksuite.com/docx/doxcnXXXXXXXXXXXXXXXXXX",
"--dry-run",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "one of --file or --from-clipboard is required") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaInsertValidateRejectsFileAndClipboardTogether(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "https://example.larksuite.com/docx/doxcnXXXXXXXXXXXXXXXXXX",
"--file", "dummy.png",
"--from-clipboard",
"--dry-run",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected mutual-exclusion error, got nil")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaInsertDryRunWithClipboardUsesPlaceholder(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "https://example.larksuite.com/docx/doxcnXXXXXXXXXXXXXXXXXX",
"--from-clipboard",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// JSON output escapes "<" and ">" as \u003c / \u003e by default.
out := stdout.String()
if !strings.Contains(out, `\u003cclipboard image\u003e`) && !strings.Contains(out, "<clipboard image>") {
t.Fatalf("dry-run output missing <clipboard image> placeholder: %s", out)
}
}
func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "https://example.larksuite.com/wiki/xxxxxx",
"--file", "dummy.png",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Resolve wiki node to docx document") {
t.Fatalf("dry-run output missing wiki resolve step: %s", out)
}
if !strings.Contains(out, "resolved_docx_token") {
t.Fatalf("dry-run output missing resolved docx token placeholder: %s", out)
}
}
func TestDocMediaUploadDryRunUsesMultipartForLargeFile(t *testing.T) {
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "large.bin", common.MaxDriveMediaUploadSinglePartSize+1)
cmd := &cobra.Command{Use: "docs +media-upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("parent-type", "", "")
cmd.Flags().String("parent-node", "", "")
cmd.Flags().String("doc-id", "", "")
if err := cmd.Flags().Set("file", "./large.bin"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("parent-type", "docx_file"); err != nil {
t.Fatalf("set --parent-type: %v", err)
}
if err := cmd.Flags().Set("parent-node", "blk_parent"); err != nil {
t.Fatalf("set --parent-node: %v", err)
}
dry := decodeDocDryRun(t, DocMediaUpload.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if dry.Description != "chunked media upload (files > 20MB)" {
t.Fatalf("dry-run description = %q", dry.Description)
}
if len(dry.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(dry.API))
}
if dry.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("first URL = %q, want upload_prepare", dry.API[0].URL)
}
if dry.API[1].URL != "/open-apis/drive/v1/medias/upload_part" {
t.Fatalf("second URL = %q, want upload_part", dry.API[1].URL)
}
if dry.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("third URL = %q, want upload_finish", dry.API[2].URL)
}
if got, _ := dry.API[0].Body["parent_node"].(string); got != "blk_parent" {
t.Fatalf("prepare parent_node = %q, want %q", got, "blk_parent")
}
}
func TestDocMediaInsertDryRunUsesMultipartForLargeFile(t *testing.T) {
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "large.bin", common.MaxDriveMediaUploadSinglePartSize+1)
cmd := &cobra.Command{Use: "docs +media-insert"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("doc", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("align", "", "")
cmd.Flags().String("caption", "", "")
if err := cmd.Flags().Set("doc", "doxcnDryRunLarge"); err != nil {
t.Fatalf("set --doc: %v", err)
}
if err := cmd.Flags().Set("file", "./large.bin"); err != nil {
t.Fatalf("set --file: %v", err)
}
dry := decodeDocDryRun(t, DocMediaInsert.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if dry.Description != "4-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)" {
t.Fatalf("dry-run description = %q", dry.Description)
}
if len(dry.API) != 6 {
t.Fatalf("expected 6 API calls, got %d", len(dry.API))
}
if dry.API[2].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("third URL = %q, want upload_prepare", dry.API[2].URL)
}
if dry.API[3].URL != "/open-apis/drive/v1/medias/upload_part" {
t.Fatalf("fourth URL = %q, want upload_part", dry.API[3].URL)
}
if dry.API[4].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("fifth URL = %q, want upload_finish", dry.API[4].URL)
}
if dry.API[5].URL != "/open-apis/docx/v1/documents/doxcnDryRunLarge/blocks/batch_update" {
t.Fatalf("last URL = %q, want batch_update", dry.API[5].URL)
}
if !strings.Contains(dry.API[2].Desc, "[3a]") {
t.Fatalf("upload_prepare desc = %q, want [3a] step marker", dry.API[2].Desc)
}
if !strings.Contains(dry.API[3].Desc, "[3b]") {
t.Fatalf("upload_part desc = %q, want [3b] step marker", dry.API[3].Desc)
}
if !strings.Contains(dry.API[4].Desc, "[3c]") {
t.Fatalf("upload_finish desc = %q, want [3c] step marker", dry.API[4].Desc)
}
if !strings.Contains(dry.API[5].Desc, "[4]") {
t.Fatalf("batch_update desc = %q, want [4] step marker", dry.API[5].Desc)
}
}
func TestUploadDocMediaFileWithContentUsesSinglePartUpload(t *testing.T) {
// Clipboard path: in-memory bytes (no FilePath) route through
// UploadDriveMediaAll when small enough. This also exercises the
// drive_route_token extra built from docID.
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-upload-content-app"))
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_content_123"},
},
}
reg.Register(uploadStub)
runtime := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "docs +media-upload"},
docsTestConfigWithAppID("docs-upload-content-app"),
f,
core.AsBot,
)
payload := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a} // PNG magic bytes
fileToken, err := uploadDocMediaFile(runtime, UploadDocMediaFileConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: "blk_parent",
DocID: "doxcnDocID123",
})
if err != nil {
t.Fatalf("uploadDocMediaFile() error: %v", err)
}
if fileToken != "file_content_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_content_123")
}
if !strings.Contains(string(uploadStub.CapturedBody), `drive_route_token`) {
t.Fatalf("expected drive_route_token in extra, captured body did not include it")
}
}
func TestUploadDocMediaFileWithContentUsesMultipart(t *testing.T) {
// Clipboard path: in-memory bytes route through UploadDriveMediaMultipart
// when size exceeds the single-part threshold.
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-upload-content-multi"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_content_multi",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_content_multi_done"},
},
})
runtime := common.TestNewRuntimeContextForAPI(
context.Background(),
&cobra.Command{Use: "docs +media-upload"},
docsTestConfigWithAppID("docs-upload-content-multi"),
f,
core.AsBot,
)
size := common.MaxDriveMediaUploadSinglePartSize + 1
payload := bytes.Repeat([]byte{0xAB}, int(size))
fileToken, err := uploadDocMediaFile(runtime, UploadDocMediaFileConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "blk_parent",
// no DocID → no drive_route_token extra
})
if err != nil {
t.Fatalf("uploadDocMediaFile() error: %v", err)
}
if fileToken != "file_content_multi_done" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_content_multi_done")
}
}
func TestDocMediaInsertExecuteFromClipboard(t *testing.T) {
// Covers the Execute clipboard branch end-to-end: read synthetic bytes,
// resolve docx root, create block, upload in-memory content, bind to block.
prev := readClipboardImage
t.Cleanup(func() { readClipboardImage = prev })
payload := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xAA, 0xBB}
readClipboardImage = func() ([]byte, error) { return payload, nil }
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-clipboard-exec-app"))
documentID := "doxcnClipboardExec1"
// Step 1: GET root block
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docx/v1/documents/" + documentID + "/blocks/" + documentID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"block": map[string]interface{}{
"block_id": documentID,
"children": []interface{}{"existing_block"},
},
},
},
})
// Step 2: POST create child block
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docx/v1/documents/" + documentID + "/blocks/" + documentID + "/children",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"children": []interface{}{
map[string]interface{}{"block_id": "new_image_block"},
},
},
},
})
// Step 3: POST upload_all for in-memory bytes
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_clip_abc"},
},
}
reg.Register(uploadStub)
// Step 4: PATCH batch_update
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/docx/v1/documents/" + documentID + "/blocks/batch_update",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", documentID,
"--from-clipboard",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v — stderr: %s", err, stderr.String())
}
// stderr should show clipboard read + file name "clipboard.png"
if !strings.Contains(stderr.String(), "Reading image from clipboard") {
t.Errorf("stderr missing clipboard-read log: %s", stderr.String())
}
if !strings.Contains(stderr.String(), "clipboard.png") {
t.Errorf("stderr missing clipboard.png file name: %s", stderr.String())
}
// stdout should include the file_token
if !strings.Contains(stdout.String(), "file_clip_abc") {
t.Errorf("stdout missing file_token: %s", stdout.String())
}
// Upload multipart body should contain the synthetic payload bytes.
if !bytes.Contains(uploadStub.CapturedBody, payload) {
t.Errorf("upload body missing clipboard payload bytes")
}
}
func TestDocMediaInsertExecuteClipboardReadError(t *testing.T) {
// Covers the early-return when clipboard read fails (no osascript etc).
prev := readClipboardImage
t.Cleanup(func() { readClipboardImage = prev })
readClipboardImage = func() ([]byte, error) {
return nil, fmt.Errorf("clipboard image upload is not supported on test")
}
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-clipboard-err-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "doxcnXXXXXXXXXXXXXXXXXX",
"--from-clipboard",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected clipboard read error, got nil")
}
if !strings.Contains(err.Error(), "clipboard image upload is not supported") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaInsertExecuteResolvesWikiBeforeFileCheck(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-insert-exec-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "doxcnResolved123",
},
},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
"--doc", "https://example.larksuite.com/wiki/xxxxxx",
"--file", "missing.png",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected file-not-found error, got nil")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "Resolved wiki to docx") {
t.Fatalf("stderr missing wiki resolution log: %s", stderr.String())
}
}
func TestDocMediaDownloadRejectsOverwriteWithoutFlag(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-overwrite-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("new"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
if err := os.WriteFile("download.bin", []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "download.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected overwrite protection error, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 404,
Body: "not found",
Headers: http.Header{"Content-Type": []string{"text/plain"}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "download.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP error, got nil")
}
if !strings.Contains(err.Error(), "HTTP 404") {
t.Fatalf("unexpected error: %v", err)
}
if _, statErr := os.Stat(filepath.Join(tmpDir, "download.bin")); !os.IsNotExist(statErr) {
t.Fatalf("download target should not be created, statErr=%v", statErr)
}
}
func TestDocMediaDownloadAppendsExtensionFromContentDispositionFilename(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-disposition-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename="drive_registry_config_addition.csv"`},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "download",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "download.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
}
}
func TestDocMediaDownloadAppendsExtensionForTrailingDotOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-trailing-dot-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"text/csv; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "typed.",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "typed.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
}
}
func TestDocMediaPreviewDryRunUsesMediaEndpoint(t *testing.T) {
cmd := &cobra.Command{Use: "docs +media-preview"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("output", "", "")
if err := cmd.Flags().Set("token", "tok_preview"); err != nil {
t.Fatalf("set --token: %v", err)
}
if err := cmd.Flags().Set("output", "./asset"); err != nil {
t.Fatalf("set --output: %v", err)
}
dry := decodeDocDryRun(t, DocMediaPreview.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if len(dry.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(dry.API))
}
if dry.API[0].Desc != "Preview document media file" {
t.Fatalf("dry-run api desc = %q", dry.API[0].Desc)
}
if dry.API[0].URL != "/open-apis/drive/v1/medias/tok_preview/preview_download" {
t.Fatalf("URL = %q, want media preview endpoint", dry.API[0].URL)
}
if got, _ := dry.API[0].Params["preview_type"].(string); got != PreviewType_SOURCE_FILE {
t.Fatalf("preview_type = %q, want %q", got, PreviewType_SOURCE_FILE)
}
}
func TestDocMediaPreviewRejectsOverwriteWithoutFlag(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-overwrite-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 200,
Body: []byte("new"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
if err := os.WriteFile("preview.bin", []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected overwrite protection error, got nil")
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDocMediaPreviewRejectsHTTPErrorBeforeWrite(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 404,
Body: "not found",
Headers: http.Header{"Content-Type": []string{"text/plain"}},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview.bin",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP error, got nil")
}
if !strings.Contains(err.Error(), "HTTP 404") {
t.Fatalf("unexpected error: %v", err)
}
if _, statErr := os.Stat(filepath.Join(tmpDir, "preview.bin")); !os.IsNotExist(statErr) {
t.Fatalf("preview target should not be created, statErr=%v", statErr)
}
}
func TestDocMediaPreviewAppendsExtensionFromRFC5987Filename(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-disposition-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Disposition": []string{`attachment; filename*=UTF-8''drive_registry_config_addition.csv`},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "preview.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview file at %q: %v", wantPath, err)
}
}
func TestDocMediaPreviewAppendsExtensionForTrailingDotOutput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-preview-trailing-dot-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/preview_download?preview_type=" + PreviewType_SOURCE_FILE,
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename*=UTF-8''drive_registry_config_addition.csv`},
"Content-Type": []string{"application/octet-stream"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaPreview, []string{
"+media-preview",
"--token", "tok_123",
"--output", "preview.",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "preview.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview file at %q: %v", wantPath, err)
}
}
func TestDocMediaDownloadAppendsExtensionFromContentTypeMapping(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-content-type-app"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/tok_123/download",
Status: 200,
Body: []byte("a,b,c\n1,2,3\n"),
Headers: http.Header{
"Content-Type": []string{"text/csv; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocMediaDownload, []string{
"+media-download",
"--token", "tok_123",
"--output", "typed",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := decodeDocCommandOutput(t, stdout)
wantPath := mustDocSafeOutputPath(t, "typed.csv")
if got.Data.SavedPath != wantPath {
t.Fatalf("saved_path = %q, want %q", got.Data.SavedPath, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected downloaded file at %q: %v", wantPath, err)
}
}
type docDryRunOutput struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
type docCommandOutput struct {
OK bool `json:"ok"`
Data struct {
SavedPath string `json:"saved_path"`
SizeBytes int64 `json:"size_bytes"`
ContentType string `json:"content_type"`
} `json:"data"`
}
func writeSizedDocTestFile(t *testing.T, name string, size int64) {
t.Helper()
fh, err := os.Create(name)
if err != nil {
t.Fatalf("Create(%q) error: %v", name, err)
}
if err := fh.Truncate(size); err != nil {
t.Fatalf("Truncate(%q) error: %v", name, err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close(%q) error: %v", name, err)
}
}
func decodeDocDryRun(t *testing.T, dryAPI *common.DryRunAPI) docDryRunOutput {
t.Helper()
raw, err := json.Marshal(dryAPI)
if err != nil {
t.Fatalf("marshal dry-run output: %v", err)
}
var dry docDryRunOutput
if err := json.Unmarshal(raw, &dry); err != nil {
t.Fatalf("decode dry-run output: %v", err)
}
return dry
}
func decodeDocCommandOutput(t *testing.T, stdout *bytes.Buffer) docCommandOutput {
t.Helper()
var out docCommandOutput
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("decode command output: %v; output=%s", err, stdout.String())
}
return out
}
func mustDocSafeOutputPath(t *testing.T, output string) string {
t.Helper()
path, err := validate.SafeOutputPath(output)
if err != nil {
t.Fatalf("SafeOutputPath(%q) error: %v", output, err)
}
return path
}