mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Compare commits
7 Commits
feat/short
...
feat/svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c6459966c | ||
|
|
37986331f4 | ||
|
|
3bbf823ce9 | ||
|
|
e43a57ce14 | ||
|
|
edf7ad81dd | ||
|
|
d98ef05dc7 | ||
|
|
24ce3ec151 |
@@ -90,6 +90,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
|
||||
|
||||
@@ -718,3 +718,23 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if gotOpts.Method != "GET" {
|
||||
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,10 +527,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
@@ -557,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.GetService()) {
|
||||
domains[sc.GetService()] = true
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
@@ -580,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
|
||||
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
|
||||
// Empty AuthTypes defaults to ["user"].
|
||||
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
|
||||
@@ -64,13 +64,12 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
svc := sc.GetService()
|
||||
if !seen[svc] {
|
||||
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
|
||||
dm := buildDomainMeta(svc, lang)
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
seen[svc] = true
|
||||
seen[sc.Service] = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := &common.Shortcut{AuthTypes: nil}
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected default to support 'user'")
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected to support 'user'")
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
|
||||
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
sc := common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
if shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected bot-only to NOT support 'user'")
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.GetService() != "" {
|
||||
seen[s.GetService()] = true
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.GetCommand(),
|
||||
Method: sc.Command,
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
@@ -148,12 +148,11 @@ func diagBuild(domains []string) diagOutput {
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range authTypes {
|
||||
for _, a := range sc.AuthTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
@@ -154,8 +154,8 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
|
||||
if risk == "high-risk-write" {
|
||||
|
||||
@@ -765,3 +765,22 @@ func TestDetectFileFields(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
|
||||
var captured *ServiceMethodOptions
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(),
|
||||
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
|
||||
func(opts *ServiceMethodOptions) error {
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--json should be accepted without error, got: %v", err)
|
||||
}
|
||||
if captured == nil {
|
||||
t.Fatal("expected runF to be called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
|
||||
// cross-field semantic failures need their own subtype here; per-field
|
||||
// failures (required missing / enum invalid / typed-primitive format) reuse
|
||||
// SubtypeInvalidArgument.
|
||||
const (
|
||||
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
|
||||
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
|
||||
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutSubtypes_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Subtype
|
||||
want string
|
||||
}{
|
||||
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
|
||||
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
|
||||
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.got) != tt.want {
|
||||
t.Errorf("got %q, want %q", string(tt.got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ChatID is a typed Lark chat identifier with the "oc_" prefix.
|
||||
// Satisfies common.Validatable.
|
||||
type ChatID string
|
||||
|
||||
// ValidateValue checks the oc_ prefix and trims whitespace. Empty values are
|
||||
// rejected here even though required-ness is enforced by the binder; this
|
||||
// keeps the type safe to call as a standalone validator.
|
||||
func (id ChatID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := strings.TrimSpace(string(id))
|
||||
if s == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "chat ID is required",
|
||||
Hint: "pass --chat-id oc_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, "oc_") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "invalid chat ID format: expected oc_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestChatID_ValidatePass(t *testing.T) {
|
||||
id := ChatID("oc_abc123")
|
||||
if err := id.ValidateValue(nil, "chat-id"); err != nil {
|
||||
t.Errorf("oc_ prefix should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatID_ValidateReject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"wrong prefix", "ou_abc"},
|
||||
{"random", "abc123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ChatID(tt.v).ValidateValue(nil, "chat-id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
|
||||
}
|
||||
if ve.Param != "chat-id" {
|
||||
t.Errorf("Param = %q, want chat-id", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MediaInput is the tri-state media-field value used by im image/file/video/
|
||||
// audio flags: URL, "img_xxx"/"file_xxx" key, or cwd-relative local path.
|
||||
// URL and key forms bypass path safety checks; local paths go through the
|
||||
// same SafePath rules. Does not emit absolute paths in hints (log safety).
|
||||
type MediaInput string
|
||||
|
||||
// IsURL reports whether the value looks like an http(s) URL.
|
||||
func (m MediaInput) IsURL() bool {
|
||||
s := string(m)
|
||||
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||
}
|
||||
|
||||
// IsMediaKey reports whether the value is an already-uploaded media key.
|
||||
func (m MediaInput) IsMediaKey() bool {
|
||||
s := string(m)
|
||||
return strings.HasPrefix(s, "img_") || strings.HasPrefix(s, "file_")
|
||||
}
|
||||
|
||||
func (m MediaInput) ValidateValue(rt *common.RuntimeContext, flagName string) error {
|
||||
if string(m) == "" {
|
||||
return nil
|
||||
}
|
||||
if m.IsURL() || m.IsMediaKey() {
|
||||
return nil
|
||||
}
|
||||
return SafePath(m).ValidateValue(rt, flagName)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaInput_AcceptURL(t *testing.T) {
|
||||
for _, v := range []string{"https://example.com/x.png", "http://a.b/y"} {
|
||||
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("URL %q should pass: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_AcceptKey(t *testing.T) {
|
||||
for _, v := range []string{"img_abc123", "file_xyz"} {
|
||||
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("key %q should pass: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_RejectAbsolutePath(t *testing.T) {
|
||||
if err := MediaInput("/etc/passwd").ValidateValue(nil, "image"); err == nil {
|
||||
t.Fatal("absolute path must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaInput_AcceptRelativePath(t *testing.T) {
|
||||
if err := MediaInput("./pic.png").ValidateValue(nil, "image"); err != nil {
|
||||
t.Errorf("relative path should pass: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SafePath is a strict cwd-relative file path. Absolute paths and ".."
|
||||
// segments are rejected. Does NOT emit absolute path back to stderr in any
|
||||
// hint (log safety).
|
||||
type SafePath string
|
||||
|
||||
func (p SafePath) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := string(p)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(s) {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must be cwd-relative; absolute paths are rejected",
|
||||
Hint: "use a relative path like ./file.txt",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
// Check the RAW input for ".." segments before filepath.Clean collapses
|
||||
// them — Clean turns "a/../b" into "b", which would otherwise hide a
|
||||
// parent-traversal segment the user actually typed. Split on both
|
||||
// separators so "\.." on Windows-style input is caught too.
|
||||
for _, seg := range strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||
if seg == ".." {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must not contain '..' segments",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
}
|
||||
clean := filepath.Clean(s)
|
||||
if strings.HasPrefix(clean, "..") || strings.Contains(clean, "/../") || strings.Contains(clean, `\..\`) {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "path must not contain '..' segments",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestSafePath_AcceptRelative(t *testing.T) {
|
||||
for _, p := range []string{"local.txt", "./sub/dir/x", "a/b/c"} {
|
||||
if err := SafePath(p).ValidateValue(nil, "file"); err != nil {
|
||||
t.Errorf("%q should pass, got: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectAbsolute(t *testing.T) {
|
||||
err := SafePath("/etc/passwd").ValidateValue(nil, "file")
|
||||
if err == nil {
|
||||
t.Fatal("absolute path must be rejected")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "file" {
|
||||
t.Errorf("wrong wrap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectDotDot(t *testing.T) {
|
||||
err := SafePath("../leak").ValidateValue(nil, "file")
|
||||
if err == nil {
|
||||
t.Fatal("'..' segment must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePath_RejectMidPathDotDot(t *testing.T) {
|
||||
// filepath.Clean collapses "a/../b" to "b"; the raw-segment scan must
|
||||
// still reject it because the user literally typed a parent segment.
|
||||
for _, p := range []string{"a/../b", "sub/../../etc", `win\..\x`} {
|
||||
if err := SafePath(p).ValidateValue(nil, "file"); err == nil {
|
||||
t.Errorf("%q should be rejected (raw .. segment)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SpreadsheetRef is a Lark Sheets reference: either a raw "shtcn..." token
|
||||
// or a feishu.cn/larksuite.com URL containing one. Normalize extracts the
|
||||
// token from URLs; ValidateValue checks the final token shape. URL→token is
|
||||
// a business-canonicalisation hint and is safe to emit to stderr.
|
||||
type SpreadsheetRef string
|
||||
|
||||
func (s SpreadsheetRef) Normalize(_ context.Context, raw string) (SpreadsheetRef, []string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
if !strings.Contains(trimmed, "://") {
|
||||
return SpreadsheetRef(trimmed), nil, nil
|
||||
}
|
||||
for _, seg := range strings.Split(trimmed, "/") {
|
||||
if strings.HasPrefix(seg, "shtcn") {
|
||||
// Strip any ?query or #fragment suffix so a URL like
|
||||
// .../shtcnXXX?sheet=0#row=5 yields a clean token, not one
|
||||
// polluted by trailing parameters that would later pass the
|
||||
// prefix-only ValidateValue check.
|
||||
token := seg
|
||||
if i := strings.IndexAny(token, "?#"); i >= 0 {
|
||||
token = token[:i]
|
||||
}
|
||||
return SpreadsheetRef(token), []string{"extracted spreadsheet token from URL"}, nil
|
||||
}
|
||||
}
|
||||
return "", nil, &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "URL does not contain a recognisable spreadsheet token",
|
||||
},
|
||||
Param: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (s SpreadsheetRef) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
v := strings.TrimSpace(string(s))
|
||||
if v == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "spreadsheet reference is required",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(v, "shtcn") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "spreadsheet token must start with 'shtcn'",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadsheetRef_NormalizeFromURL(t *testing.T) {
|
||||
url := "https://feishu.cn/sheets/shtcnAbCdEf01234567"
|
||||
v, hints, err := SpreadsheetRef(url).Normalize(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize error: %v", err)
|
||||
}
|
||||
if string(v) != "shtcnAbCdEf01234567" {
|
||||
t.Errorf("expected extracted token, got %q", v)
|
||||
}
|
||||
if len(hints) == 0 {
|
||||
t.Errorf("expected at least one hint about URL extraction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_NormalizeStripsQueryAndFragment(t *testing.T) {
|
||||
for _, url := range []string{
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0",
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567#row=5",
|
||||
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0#row=5",
|
||||
} {
|
||||
v, _, err := SpreadsheetRef(url).Normalize(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%q) error: %v", url, err)
|
||||
}
|
||||
if string(v) != "shtcnAbCdEf01234567" {
|
||||
t.Errorf("Normalize(%q): expected clean token, got %q", url, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_NormalizePassThroughToken(t *testing.T) {
|
||||
v, hints, err := SpreadsheetRef("shtcnXyz").Normalize(context.Background(), "shtcnXyz")
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize error: %v", err)
|
||||
}
|
||||
if string(v) != "shtcnXyz" {
|
||||
t.Errorf("raw token should pass through, got %q", v)
|
||||
}
|
||||
if len(hints) != 0 {
|
||||
t.Errorf("no hint expected for raw token, got %v", hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_ValidateValueRejectsEmpty(t *testing.T) {
|
||||
if err := SpreadsheetRef("").ValidateValue(nil, "spreadsheet"); err == nil {
|
||||
t.Fatal("empty value should fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadsheetRef_ValidateValueAcceptsToken(t *testing.T) {
|
||||
if err := SpreadsheetRef("shtcnAbCd").ValidateValue(nil, "spreadsheet"); err != nil {
|
||||
t.Errorf("token should pass: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// UserOpenID is a typed Lark user identifier with the "ou_" prefix.
|
||||
type UserOpenID string
|
||||
|
||||
func (id UserOpenID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
|
||||
s := strings.TrimSpace(string(id))
|
||||
if s == "" {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "user open_id is required",
|
||||
Hint: "pass --user-id ou_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, "ou_") {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "invalid user open_id format: expected ou_xxx",
|
||||
},
|
||||
Param: flagName,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package argstype
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestUserOpenID_ValidatePass(t *testing.T) {
|
||||
if err := UserOpenID("ou_abc").ValidateValue(nil, "user-id"); err != nil {
|
||||
t.Errorf("ou_ prefix should pass, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserOpenID_ValidateReject(t *testing.T) {
|
||||
for _, v := range []string{"", "oc_abc", "abc"} {
|
||||
err := UserOpenID(v).ValidateValue(nil, "user-id")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for %q", v)
|
||||
continue
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "user-id" {
|
||||
t.Errorf("wrong wrap for %q: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,795 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// fieldSpec is the binder's intermediate representation of one Args field.
|
||||
// It captures both reflection metadata and the parsed tag values so later
|
||||
// binder stages don't repeat the parsing work.
|
||||
type fieldSpec struct {
|
||||
GoFieldName string
|
||||
FlagName string
|
||||
Description string
|
||||
DefaultValue string
|
||||
EnumValues []string
|
||||
Input []string
|
||||
Required bool
|
||||
Hidden bool
|
||||
NoSplit bool
|
||||
IsOneOfBkt bool
|
||||
IsGroup bool
|
||||
IsPtr bool
|
||||
FieldType reflect.Type
|
||||
StructType reflect.Type
|
||||
}
|
||||
|
||||
// walkArgs reflects an Args struct (must be *T where T is struct) and
|
||||
// returns one fieldSpec per top-level field. Duplicate flag tags inside
|
||||
// the same Args struct panic at Mount time — cross-shortcut duplicates are
|
||||
// not checked (cobra's own per-command Add check covers that).
|
||||
func walkArgs(t reflect.Type) ([]fieldSpec, error) {
|
||||
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
|
||||
return nil, fmt.Errorf("Args must be *struct, got %s", t)
|
||||
}
|
||||
st := t.Elem()
|
||||
specs := make([]fieldSpec, 0, st.NumField())
|
||||
seen := map[string]string{}
|
||||
for i := 0; i < st.NumField(); i++ {
|
||||
f := st.Field(i)
|
||||
spec, err := parseFieldSpec(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.FlagName != "" {
|
||||
if owner, dup := seen[spec.FlagName]; dup {
|
||||
panic(fmt.Sprintf("duplicate flag tag %q in Args struct: fields %s and %s",
|
||||
spec.FlagName, owner, f.Name))
|
||||
}
|
||||
seen[spec.FlagName] = f.Name
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// parseFieldSpec extracts the relevant tag values from one struct field.
|
||||
// Sub-struct (OneOf bucket / group) detection is delegated to caller stages;
|
||||
// here we only set flags about the field shape.
|
||||
func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
|
||||
spec := fieldSpec{
|
||||
GoFieldName: f.Name,
|
||||
FlagName: f.Tag.Get("flag"),
|
||||
Description: f.Tag.Get("desc"),
|
||||
DefaultValue: f.Tag.Get("default"),
|
||||
}
|
||||
if enum := f.Tag.Get("enum"); enum != "" {
|
||||
spec.EnumValues = strings.Split(enum, ",")
|
||||
}
|
||||
if _, has := f.Tag.Lookup("required"); has {
|
||||
spec.Required = true
|
||||
}
|
||||
if input := f.Tag.Get("input"); input != "" {
|
||||
for _, src := range strings.Split(input, ",") {
|
||||
switch src = strings.TrimSpace(src); src {
|
||||
case "":
|
||||
// tolerate stray commas
|
||||
case File, Stdin:
|
||||
spec.Input = append(spec.Input, src)
|
||||
default:
|
||||
return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, has := f.Tag.Lookup("hidden"); has {
|
||||
spec.Hidden = true
|
||||
}
|
||||
switch split := strings.TrimSpace(f.Tag.Get("split")); split {
|
||||
case "", "comma":
|
||||
// default: cobra StringSlice (comma-separated, also repeatable)
|
||||
case "none":
|
||||
spec.NoSplit = true // cobra StringArray: repeatable, no comma split
|
||||
default:
|
||||
return spec, fmt.Errorf("field %s: unknown split mode %q (allowed: comma, none)", f.Name, split)
|
||||
}
|
||||
ft := f.Type
|
||||
spec.FieldType = ft
|
||||
if ft.Kind() == reflect.Ptr {
|
||||
spec.IsPtr = true
|
||||
ft = ft.Elem()
|
||||
}
|
||||
if ft.Kind() == reflect.Struct {
|
||||
spec.StructType = ft
|
||||
ptr := reflect.PointerTo(ft)
|
||||
marker := reflect.TypeOf((*OneOfMarker)(nil)).Elem()
|
||||
if ft.Implements(marker) || ptr.Implements(marker) {
|
||||
spec.IsOneOfBkt = true
|
||||
} else {
|
||||
spec.IsGroup = true
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
// Leaf field validation. Multi-value flags are []string (cobra
|
||||
// StringSlice / StringArray); any other slice element type is unsupported.
|
||||
// enum / input only make sense on plain string leaves (string or a
|
||||
// string-alias like ChatID); split only on []string. Reject mismatches at
|
||||
// Mount time instead of silently skipping or panicking at runtime.
|
||||
isStringSlice := ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String
|
||||
if ft.Kind() == reflect.Slice && !isStringSlice {
|
||||
return spec, fmt.Errorf("field %s: only []string slices are supported, got %s", f.Name, ft)
|
||||
}
|
||||
if spec.NoSplit && !isStringSlice {
|
||||
return spec, fmt.Errorf("field %s: split tag is only supported on []string fields", f.Name)
|
||||
}
|
||||
if ft.Kind() != reflect.String {
|
||||
if len(spec.EnumValues) > 0 {
|
||||
return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name)
|
||||
}
|
||||
if len(spec.Input) > 0 {
|
||||
return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name)
|
||||
}
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// registerFlags registers cobra flags for the given specs. Sub-struct fields
|
||||
// (OneOf bucket / group) recurse into their inner fields.
|
||||
func registerFlags(cmd *cobra.Command, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerFlags(cmd, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := registerLeaf(cmd, s, s.FieldType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerLeaf registers a single primitive flag based on the underlying type.
|
||||
func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error {
|
||||
if s.FlagName == "" {
|
||||
return nil
|
||||
}
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Bool:
|
||||
def := s.DefaultValue == "true"
|
||||
cmd.Flags().Bool(s.FlagName, def, s.Description)
|
||||
case reflect.Int, reflect.Int64:
|
||||
def := 0
|
||||
if s.DefaultValue != "" {
|
||||
def, _ = strconv.Atoi(s.DefaultValue)
|
||||
}
|
||||
cmd.Flags().Int(s.FlagName, def, s.Description)
|
||||
case reflect.Slice:
|
||||
// []string (validated at parse time). NoSplit → StringArray
|
||||
// (repeatable, literal); default → StringSlice (comma-separated).
|
||||
if s.NoSplit {
|
||||
cmd.Flags().StringArray(s.FlagName, nil, s.Description)
|
||||
} else {
|
||||
cmd.Flags().StringSlice(s.FlagName, nil, s.Description)
|
||||
}
|
||||
default:
|
||||
cmd.Flags().String(s.FlagName, s.DefaultValue, s.Description)
|
||||
}
|
||||
if s.Required {
|
||||
_ = cmd.MarkFlagRequired(s.FlagName)
|
||||
}
|
||||
if s.Hidden {
|
||||
_ = cmd.Flags().MarkHidden(s.FlagName)
|
||||
}
|
||||
if len(s.EnumValues) > 0 {
|
||||
vals := s.EnumValues
|
||||
cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return vals, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTypedInputs applies @file / stdin resolution to every leaf flag that
|
||||
// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups
|
||||
// (cobra flags are flat, so a nested variant's flag resolves the same way). It
|
||||
// runs before bindFlags so the file/stdin content is read back into the cobra
|
||||
// flag and then bound into the Args struct. This is the typed counterpart of
|
||||
// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty)
|
||||
// Flags slice — both ultimately call resolveInputForFlag.
|
||||
func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error {
|
||||
stdinUsed := false
|
||||
return resolveTypedInputsRec(rctx, specs, &stdinUsed)
|
||||
}
|
||||
|
||||
func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || len(s.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindFlags writes cobra-parsed values back into the Args struct. argsVal
|
||||
// is a reflect.Value of the struct (not pointer).
|
||||
func bindFlags(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
if err := bindLeaf(cmd, argsVal, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindLeaf(cmd *cobra.Command, argsVal reflect.Value, s fieldSpec) error {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.CanSet() {
|
||||
return nil
|
||||
}
|
||||
// Pointer leaves preserve "nil = not given" semantics: only allocate when
|
||||
// the user explicitly set the flag. This mirrors the OneOf bucket
|
||||
// convention (`Chat *ChatID` — nil means the variant wasn't selected) and
|
||||
// lets typed shortcuts express tri-state bool / int / string flags using
|
||||
// plain Go pointers instead of a separate Maybe[T] wrapper type.
|
||||
if s.IsPtr && !cmd.Flags().Changed(s.FlagName) {
|
||||
return nil
|
||||
}
|
||||
leafType := s.FieldType
|
||||
if leafType.Kind() == reflect.Ptr {
|
||||
leafType = leafType.Elem()
|
||||
}
|
||||
switch leafType.Kind() {
|
||||
case reflect.Bool:
|
||||
v, _ := cmd.Flags().GetBool(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(v))
|
||||
case reflect.Int, reflect.Int64:
|
||||
v, _ := cmd.Flags().GetInt(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(int64(v)).Convert(leafType))
|
||||
case reflect.Slice:
|
||||
var vals []string
|
||||
if s.NoSplit {
|
||||
vals, _ = cmd.Flags().GetStringArray(s.FlagName)
|
||||
} else {
|
||||
vals, _ = cmd.Flags().GetStringSlice(s.FlagName)
|
||||
}
|
||||
setLeaf(fv, stringsToSlice(vals, leafType))
|
||||
default:
|
||||
v, _ := cmd.Flags().GetString(s.FlagName)
|
||||
setLeaf(fv, reflect.ValueOf(v).Convert(leafType))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stringsToSlice builds a slice value of type t (kind Slice with string-kinded
|
||||
// elements) from raw strings, element-by-element via SetString. This handles a
|
||||
// plain []string, a named slice type (type IDs []string), AND a named element
|
||||
// type (type ID string → []ID) — reflect.Convert would panic on the last case
|
||||
// because []string is not convertible to []ID.
|
||||
func stringsToSlice(vals []string, t reflect.Type) reflect.Value {
|
||||
out := reflect.MakeSlice(t, len(vals), len(vals))
|
||||
for i, v := range vals {
|
||||
out.Index(i).SetString(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setLeaf(dst reflect.Value, src reflect.Value) {
|
||||
if dst.Kind() == reflect.Ptr {
|
||||
ptr := reflect.New(dst.Type().Elem())
|
||||
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
|
||||
dst.Set(ptr)
|
||||
return
|
||||
}
|
||||
dst.Set(src.Convert(dst.Type()))
|
||||
}
|
||||
|
||||
// bindBuckets allocates and populates OneOf bucket / group sub-struct fields
|
||||
// from cobra flag state. The shared bindFlags() above only writes top-level
|
||||
// leaves; this function is the framework's recursion into nested Args structs
|
||||
// so future typed shortcuts don't each have to ship a bespoke binder helper.
|
||||
//
|
||||
// Conventions:
|
||||
// - Pointer-leaf in a bucket (e.g. *string, *argstype.ChatID): set iff
|
||||
// cobra reports the flag was explicitly provided. nil means "variant not
|
||||
// selected" — the framework's runFrameworkRules and runValidateValue
|
||||
// both honor this nil/non-nil split.
|
||||
// - Non-pointer leaf in a group (e.g. a typed-primitive field inside a
|
||||
// paired group struct): always copy the cobra flag value back. Empty
|
||||
// string is a valid "not provided" sentinel for group completeness checks.
|
||||
// - Pointer-to-group / pointer-to-bucket (a nested group/bucket pointer
|
||||
// inside an outer OneOf bucket): allocate iff at least one inner flag was
|
||||
// Changed, then recurse to bind the inner fields.
|
||||
func bindBuckets(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if !s.IsOneOfBkt {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
target := fv
|
||||
if target.Kind() == reflect.Ptr {
|
||||
if target.IsNil() {
|
||||
target.Set(reflect.New(s.StructType))
|
||||
}
|
||||
target = target.Elem()
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindBucketInner(cmd, target, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bindGroups is the top-level counterpart to bindBuckets for IsGroup fields
|
||||
// (regular nested structs without an OneOf() marker). A group's inner flags
|
||||
// are registered and validated for completeness, but bindFlags above skips
|
||||
// the field; this function fills the binding gap so an Args struct can place
|
||||
// a group directly at the top level (not just nested inside an OneOf bucket).
|
||||
//
|
||||
// Conventions mirror bindBuckets / bindBucketInner:
|
||||
// - Value-type group: always populated; inner fields receive cobra flag
|
||||
// values (including defaults).
|
||||
// - Pointer-type group: allocated iff at least one inner flag was Changed,
|
||||
// so a nil group still signals "user did not engage this group at all".
|
||||
func bindGroups(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if !s.IsGroup {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
anyChanged := false
|
||||
for _, g := range inner {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
anyChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
if fv.IsNil() {
|
||||
fv.Set(reflect.New(s.StructType))
|
||||
}
|
||||
if err := bindBucketInner(cmd, fv.Elem(), inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Value-type group: populate inner fields directly.
|
||||
if err := bindBucketInner(cmd, fv, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
grandSpecs, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anyChanged := false
|
||||
for _, g := range grandSpecs {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
anyChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyChanged {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
target := fv
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if fv.IsNil() {
|
||||
fv.Set(reflect.New(s.StructType))
|
||||
}
|
||||
target = fv.Elem()
|
||||
}
|
||||
if err := bindBucketInner(cmd, target, grandSpecs); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
if s.IsPtr {
|
||||
if !cmd.Flags().Changed(s.FlagName) {
|
||||
continue
|
||||
}
|
||||
elemType := fv.Type().Elem()
|
||||
ptr := reflect.New(elemType)
|
||||
ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType, s.NoSplit))
|
||||
fv.Set(ptr)
|
||||
continue
|
||||
}
|
||||
fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type(), s.NoSplit))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketLeafValue reads a single cobra flag and returns it as a reflect.Value
|
||||
// convertible to targetType. It dispatches on the underlying kind so nested
|
||||
// bucket/group leaves typed as bool or int bind correctly instead of being
|
||||
// force-read through GetString (which would panic on reflect conversion of a
|
||||
// string into a numeric/bool type).
|
||||
func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type, noSplit bool) reflect.Value {
|
||||
kind := targetType.Kind()
|
||||
if kind == reflect.Ptr {
|
||||
kind = targetType.Elem().Kind()
|
||||
}
|
||||
switch kind {
|
||||
case reflect.Bool:
|
||||
v, _ := cmd.Flags().GetBool(flagName)
|
||||
return reflect.ValueOf(v).Convert(targetType)
|
||||
case reflect.Int, reflect.Int64:
|
||||
v, _ := cmd.Flags().GetInt(flagName)
|
||||
return reflect.ValueOf(int64(v)).Convert(targetType)
|
||||
case reflect.Slice:
|
||||
var vals []string
|
||||
if noSplit {
|
||||
vals, _ = cmd.Flags().GetStringArray(flagName)
|
||||
} else {
|
||||
vals, _ = cmd.Flags().GetStringSlice(flagName)
|
||||
}
|
||||
return stringsToSlice(vals, targetType)
|
||||
default:
|
||||
v, _ := cmd.Flags().GetString(flagName)
|
||||
return reflect.ValueOf(v).Convert(targetType)
|
||||
}
|
||||
}
|
||||
|
||||
// runNormalize invokes the Normalize method (via reflection) on every field
|
||||
// whose type implements Normalizable[T]. The canonical value is written back.
|
||||
// Plan's static type assertion can't work because Normalizable is generic —
|
||||
// the method's return type is the typed T, not any — so we dispatch by
|
||||
// reflection on method shape.
|
||||
//
|
||||
// Local-path normalization hints are dropped at the primitive layer; only
|
||||
// non-path hints reach stderr (log safety).
|
||||
func runNormalize(ctx context.Context, rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
m := fv.MethodByName("Normalize")
|
||||
if !m.IsValid() {
|
||||
continue
|
||||
}
|
||||
mt := m.Type()
|
||||
if mt.NumIn() != 2 || mt.NumOut() != 3 {
|
||||
continue
|
||||
}
|
||||
if !ctxType.AssignableTo(mt.In(0)) || mt.In(1).Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
raw := asString(fv)
|
||||
rets := m.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(raw)})
|
||||
if errRet := rets[2]; !errRet.IsNil() {
|
||||
return errRet.Interface().(error)
|
||||
}
|
||||
canon := rets[0]
|
||||
if fv.CanSet() && canon.Type().AssignableTo(fv.Type()) {
|
||||
fv.Set(canon)
|
||||
}
|
||||
hints, _ := rets[1].Interface().([]string)
|
||||
if len(hints) > 0 && rt != nil && rt.Cmd != nil {
|
||||
for _, h := range hints {
|
||||
_, _ = rt.Cmd.ErrOrStderr().Write([]byte(h + "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func asString(fv reflect.Value) string {
|
||||
if fv.Kind() == reflect.String {
|
||||
return fv.String()
|
||||
}
|
||||
if fv.Kind() == reflect.Ptr && !fv.IsNil() {
|
||||
return asString(fv.Elem())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runValidateValue calls ValidateValue on every Validatable field, recursing
|
||||
// into OneOf bucket / group sub-structs so typed-primitive leaves inside
|
||||
// nested Args structs (e.g. a typed ID primitive inside a OneOf bucket) still
|
||||
// get their format check. Returns the first error to keep error envelopes
|
||||
// deterministic.
|
||||
func runValidateValue(rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
structVal := fv
|
||||
if structVal.Kind() == reflect.Ptr {
|
||||
if structVal.IsNil() {
|
||||
continue
|
||||
}
|
||||
structVal = structVal.Elem()
|
||||
}
|
||||
// Some buckets/groups implement Validatable themselves (e.g. a
|
||||
// raw-JSON variant that checks JSON validity in its ValidateValue).
|
||||
// Call the struct-level check BEFORE recursing into inner fields so
|
||||
// the cross-field rule fires even when none of the inner leaves are
|
||||
// individually Validatable.
|
||||
if fv.CanInterface() {
|
||||
if val, ok := fv.Interface().(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runValidateValue(rt, structVal, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Leaf field. Skip nil pointers (variant not selected).
|
||||
if fv.Kind() == reflect.Ptr && fv.IsNil() {
|
||||
continue
|
||||
}
|
||||
if !fv.CanInterface() {
|
||||
continue
|
||||
}
|
||||
v := fv.Interface()
|
||||
if val, ok := v.(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Pointer leaf: dereference and re-check (for value-receiver
|
||||
// ValidateValue methods on the pointee type).
|
||||
if fv.Kind() == reflect.Ptr {
|
||||
if val, ok := fv.Elem().Interface().(Validatable); ok {
|
||||
if err := val.ValidateValue(rt, s.FlagName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runFrameworkRules enforces OneOf / group / required / enum invariants and
|
||||
// returns a *errs.ValidationError on the first violation. Each rule's
|
||||
// stderr-facing param is the Args field name (not the inner struct type name),
|
||||
// so OneOf bucket errors mention the user-visible field (e.g. "Target") rather
|
||||
// than the implementation-detail type name behind it.
|
||||
//
|
||||
// Recurses into OneOf bucket sub-structs so a nested group inside a bucket
|
||||
// still gets its checkGroup fire automatically.
|
||||
func runFrameworkRules(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
|
||||
for _, s := range specs {
|
||||
fv := argsVal.FieldByName(s.GoFieldName)
|
||||
switch {
|
||||
case s.IsOneOfBkt:
|
||||
if err := checkOneOf(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
structVal := fv
|
||||
if structVal.Kind() == reflect.Ptr {
|
||||
if structVal.IsNil() {
|
||||
continue
|
||||
}
|
||||
structVal = structVal.Elem()
|
||||
}
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runFrameworkRules(cmd, structVal, inner); err != nil {
|
||||
return err
|
||||
}
|
||||
case s.IsGroup:
|
||||
if err := checkGroup(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if err := checkEnumAndRequired(cmd, fv, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkOneOf counts how many variants the user attempted inside the OneOf
|
||||
// bucket; exactly one must be attempted. A variant counts as "attempted" if:
|
||||
// - it's a simple pointer leaf (e.g. *string, *ChatID) and its own flag was
|
||||
// explicitly provided, or
|
||||
// - it's a nested group / bucket and ANY of its inner flags was explicitly
|
||||
// provided. No "trigger" field is required — supplying any flag of a
|
||||
// group is enough to mark the variant as attempted, and a follow-up
|
||||
// checkGroup catches the partial-fill case with shortcut_group_incomplete.
|
||||
func checkOneOf(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var triggered []string
|
||||
for _, child := range inner {
|
||||
// Simple pointer leaf variant: its own flag is the signal.
|
||||
if child.IsPtr && !child.IsOneOfBkt && !child.IsGroup {
|
||||
if child.FlagName != "" && cmd.Flags().Changed(child.FlagName) {
|
||||
triggered = append(triggered, "--"+child.FlagName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Nested group / bucket variant: any inner flag Changed counts.
|
||||
if child.IsGroup || child.IsOneOfBkt {
|
||||
grand, _ := walkArgs(reflect.PointerTo(child.StructType))
|
||||
for _, g := range grand {
|
||||
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
|
||||
triggered = append(triggered, "--"+g.FlagName)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(triggered) {
|
||||
case 0:
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutOneOfMissing,
|
||||
Message: "exactly one " + s.GoFieldName + " variant must be provided",
|
||||
},
|
||||
Param: s.GoFieldName,
|
||||
}
|
||||
case 1:
|
||||
return nil
|
||||
default:
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutOneOfMultiple,
|
||||
Message: "choose only one of " + strings.Join(triggered, ", "),
|
||||
},
|
||||
Param: s.GoFieldName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkGroup ensures all fields of a group sub-struct were provided when
|
||||
// the group's trigger (or first field) was set.
|
||||
func checkGroup(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
inner, err := walkArgs(reflect.PointerTo(s.StructType))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anySet := false
|
||||
var missing []string
|
||||
for _, child := range inner {
|
||||
if child.FlagName == "" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Changed(child.FlagName) {
|
||||
anySet = true
|
||||
continue
|
||||
}
|
||||
// Flags with a default value are never "missing" — the default is a
|
||||
// valid implicit value (e.g. an enum flag that defaults to a value).
|
||||
// Only flags without defaults need explicit user input when the
|
||||
// group is partially populated.
|
||||
if child.DefaultValue != "" {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, "--"+child.FlagName)
|
||||
}
|
||||
if anySet && len(missing) > 0 {
|
||||
// Group errors use the inner struct TYPE name (the group struct's own
|
||||
// name), not the Args field name. This matches the spec's
|
||||
// "shortcut_group_incomplete" envelope contract — callers identify
|
||||
// the *kind* of group that is incomplete, which is the type name.
|
||||
// OneOf bucket errors use the field name instead (see checkOneOf).
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeShortcutGroupIncomplete,
|
||||
Message: s.StructType.Name() + " requires " + strings.Join(missing, ", "),
|
||||
},
|
||||
Param: s.StructType.Name(),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkEnumAndRequired enforces enum membership. Required-presence is already
|
||||
// enforced at cobra level via MarkFlagRequired, so this only adds the enum
|
||||
// check.
|
||||
func checkEnumAndRequired(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
|
||||
if len(s.EnumValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
v, _ := cmd.Flags().GetString(s.FlagName)
|
||||
if v == "" && s.DefaultValue == "" {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range s.EnumValues {
|
||||
if v == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: "--" + s.FlagName + ": value must be one of " + strings.Join(s.EnumValues, "|"),
|
||||
},
|
||||
Param: s.FlagName,
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// --- top-level pointer leaf: nil = not given (mirrors OneOf bucket convention) ---
|
||||
|
||||
type ptrLeafArgs struct {
|
||||
Notify *bool `flag:"notify"`
|
||||
Limit *int `flag:"limit"`
|
||||
Name *string `flag:"name"`
|
||||
}
|
||||
|
||||
func TestBindLeaf_PtrNilWhenAbsent(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &ptrLeafArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Notify != nil || out.Limit != nil || out.Name != nil {
|
||||
t.Errorf("expected all pointer leaves nil when no flag given; got Notify=%v Limit=%v Name=%v",
|
||||
out.Notify, out.Limit, out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindLeaf_PtrSetWhenChanged covers the tri-state contract that previously
|
||||
// required Maybe[T]: a pointer leaf set to its zero value (e.g. --notify=false)
|
||||
// MUST come back non-nil so business code can tell it apart from "not given".
|
||||
func TestBindLeaf_PtrSetWhenChanged(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--notify=false", "--limit", "5", "--name", "alice"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &ptrLeafArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Notify == nil || *out.Notify != false {
|
||||
t.Errorf("Notify = %v, want non-nil false (tri-state: zero value is still 'set')", out.Notify)
|
||||
}
|
||||
if out.Limit == nil || *out.Limit != 5 {
|
||||
t.Errorf("Limit = %v, want non-nil 5", out.Limit)
|
||||
}
|
||||
if out.Name == nil || *out.Name != "alice" {
|
||||
t.Errorf("Name = %v, want non-nil alice", out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneOf bucket binding: bindBuckets / bindBucketInner / bucketLeafValue ----
|
||||
|
||||
type bcLeafBucket struct {
|
||||
S *string `flag:"lb-s"`
|
||||
I *int `flag:"lb-i"`
|
||||
B *bool `flag:"lb-b"`
|
||||
Plain string `flag:"lb-plain"` // non-pointer leaf exercises the value branch
|
||||
}
|
||||
|
||||
func (bcLeafBucket) OneOf() {}
|
||||
|
||||
type bcValueBucketArgs struct {
|
||||
Sel bcLeafBucket
|
||||
}
|
||||
|
||||
func TestBindBuckets_ValueBucketTypedLeaves(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcValueBucketArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--lb-s", "hi", "--lb-i", "7", "--lb-b", "--lb-plain", "p"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &bcValueBucketArgs{}
|
||||
val := reflect.ValueOf(out).Elem()
|
||||
if err := bindFlags(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if err := bindBuckets(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Sel.S == nil || *out.Sel.S != "hi" {
|
||||
t.Errorf("Sel.S = %v, want hi", out.Sel.S)
|
||||
}
|
||||
if out.Sel.I == nil || *out.Sel.I != 7 {
|
||||
t.Errorf("Sel.I = %v, want 7", out.Sel.I)
|
||||
}
|
||||
if out.Sel.B == nil || *out.Sel.B != true {
|
||||
t.Errorf("Sel.B = %v, want true", out.Sel.B)
|
||||
}
|
||||
if out.Sel.Plain != "p" {
|
||||
t.Errorf("Sel.Plain = %q, want p", out.Sel.Plain)
|
||||
}
|
||||
}
|
||||
|
||||
type bcPtrBucketArgs struct {
|
||||
Sel *bcLeafBucket
|
||||
}
|
||||
|
||||
func TestBindBuckets_PointerBucketAllocates(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcPtrBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--lb-s", "x"}); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
out := &bcPtrBucketArgs{}
|
||||
val := reflect.ValueOf(out).Elem()
|
||||
if err := bindBuckets(cmd, val, specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Sel == nil {
|
||||
t.Fatal("Sel pointer was not allocated")
|
||||
}
|
||||
if out.Sel.S == nil || *out.Sel.S != "x" {
|
||||
t.Errorf("Sel.S = %v, want x", out.Sel.S)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runNormalize / asString --------------------------------------------------
|
||||
|
||||
type bcNormField string
|
||||
|
||||
func (n bcNormField) Normalize(_ context.Context, raw string) (bcNormField, []string, error) {
|
||||
if raw == "boom" {
|
||||
return "", nil, errors.New("normalize failed")
|
||||
}
|
||||
return bcNormField("c:" + raw), []string{"hint: " + raw}, nil
|
||||
}
|
||||
|
||||
type bcNormArgs struct {
|
||||
Token bcNormField `flag:"token"`
|
||||
TokenPtr *bcNormField `flag:"token-ptr"`
|
||||
}
|
||||
|
||||
func TestRunNormalize_CanonicalizesAndEmitsHints(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.SetErr(&buf)
|
||||
rt := &RuntimeContext{Cmd: cmd}
|
||||
|
||||
ptr := bcNormField("xy")
|
||||
args := &bcNormArgs{Token: "raw", TokenPtr: &ptr}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
|
||||
if err := runNormalize(context.Background(), rt, reflect.ValueOf(args).Elem(), specs); err != nil {
|
||||
t.Fatalf("runNormalize: %v", err)
|
||||
}
|
||||
if args.Token != "c:raw" {
|
||||
t.Errorf("Token = %q, want c:raw", args.Token)
|
||||
}
|
||||
if got := buf.String(); got == "" || !bytes.Contains([]byte(got), []byte("hint: raw")) {
|
||||
t.Errorf("stderr = %q, want it to contain the normalize hint", got)
|
||||
}
|
||||
}
|
||||
|
||||
type bcBadNormArgs struct {
|
||||
Token bcNormField `flag:"token"`
|
||||
}
|
||||
|
||||
func TestRunNormalize_PropagatesError(t *testing.T) {
|
||||
args := &bcBadNormArgs{Token: "boom"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
err := runNormalize(context.Background(), nil, reflect.ValueOf(args).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Normalize")
|
||||
}
|
||||
}
|
||||
|
||||
// --- checkGroup (via runFrameworkRules) ---------------------------------------
|
||||
|
||||
type bcGroupBody struct {
|
||||
A string `flag:"g-a"`
|
||||
B string `flag:"g-b"`
|
||||
C string `flag:"g-c" default:"x"` // default means never "missing"
|
||||
}
|
||||
|
||||
type bcGroupArgs struct {
|
||||
Grp bcGroupBody
|
||||
}
|
||||
|
||||
func TestCheckGroup_IncompleteReportsMissing(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
// Only --g-a set: B is missing (no default), C has a default so it is fine.
|
||||
_ = cmd.ParseFlags([]string{"--g-a", "v"})
|
||||
out := &bcGroupArgs{}
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected group_incomplete error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
|
||||
t.Errorf("Subtype = %q, want group_incomplete", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_CompleteAndUntouched(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
|
||||
|
||||
// Complete: A and B provided.
|
||||
cmd1 := &cobra.Command{Use: "t1"}
|
||||
_ = registerFlags(cmd1, specs)
|
||||
_ = cmd1.ParseFlags([]string{"--g-a", "1", "--g-b", "2"})
|
||||
if err := runFrameworkRules(cmd1, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("complete group should pass, got %v", err)
|
||||
}
|
||||
|
||||
// Untouched: nothing set → group rule does not fire.
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
_ = registerFlags(cmd2, specs)
|
||||
_ = cmd2.ParseFlags(nil)
|
||||
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("untouched group should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- checkEnumAndRequired (via runFrameworkRules) -----------------------------
|
||||
|
||||
type bcEnumArgs struct {
|
||||
Mode string `flag:"mode" enum:"a,b,c"`
|
||||
}
|
||||
|
||||
func TestCheckEnum(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bcEnumArgs{}))
|
||||
|
||||
// Invalid value.
|
||||
cmd1 := &cobra.Command{Use: "t1"}
|
||||
_ = registerFlags(cmd1, specs)
|
||||
_ = cmd1.ParseFlags([]string{"--mode", "z"})
|
||||
err := runFrameworkRules(cmd1, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid_argument error for bad enum value")
|
||||
}
|
||||
if ve := mustValidationError(t, err); ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
|
||||
}
|
||||
|
||||
// Valid value.
|
||||
cmd2 := &cobra.Command{Use: "t2"}
|
||||
_ = registerFlags(cmd2, specs)
|
||||
_ = cmd2.ParseFlags([]string{"--mode", "b"})
|
||||
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("valid enum value should pass, got %v", err)
|
||||
}
|
||||
|
||||
// Empty (no default) → enum check skipped.
|
||||
cmd3 := &cobra.Command{Use: "t3"}
|
||||
_ = registerFlags(cmd3, specs)
|
||||
_ = cmd3.ParseFlags(nil)
|
||||
if err := runFrameworkRules(cmd3, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
|
||||
t.Errorf("empty enum value should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runValidateValue recursion into a group sub-struct -----------------------
|
||||
|
||||
type bcValGroup struct {
|
||||
ID dummyValidatable `flag:"vg-id"`
|
||||
}
|
||||
|
||||
type bcValGroupArgs struct {
|
||||
Grp bcValGroup
|
||||
}
|
||||
|
||||
func TestRunValidateValue_RecursesIntoGroup(t *testing.T) {
|
||||
args := &bcValGroupArgs{}
|
||||
specs, _ := walkArgs(reflect.TypeOf(args))
|
||||
rt := &RuntimeContext{}
|
||||
if err := runValidateValue(rt, reflect.ValueOf(args).Elem(), specs); err != nil {
|
||||
t.Errorf("runValidateValue into group: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// newTypedInputRuntime registers the given specs on a fresh cobra command,
|
||||
// parses argv, and returns a RuntimeContext wired with a fake stdin — the
|
||||
// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go.
|
||||
func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags(argv); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
return &RuntimeContext{
|
||||
Cmd: cmd,
|
||||
Factory: &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- @file / stdin on typed shortcuts -------------------------------------
|
||||
|
||||
type fileInputArgs struct {
|
||||
Content string `flag:"content" input:"file,stdin"`
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_File(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
body := "## Title\n\nbody from a file\n"
|
||||
if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Content != body {
|
||||
t.Errorf("Content = %q, want file body %q", out.Content, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_Stdin(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Content != "piped stdin body" {
|
||||
t.Errorf("Content = %q, want stdin body", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &fileInputArgs{}
|
||||
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Content != "literal text" {
|
||||
t.Errorf("Content = %q, want unchanged literal", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A OneOf variant flag that declares @file/stdin must resolve too — the binder
|
||||
// recurses into buckets because cobra flags are flat regardless of nesting.
|
||||
type nestedInputVariant struct {
|
||||
Body *string `flag:"body" input:"file,stdin"`
|
||||
Raw *string `flag:"raw"`
|
||||
}
|
||||
|
||||
func (nestedInputVariant) OneOf() {}
|
||||
|
||||
type nestedInputArgs struct {
|
||||
Content nestedInputVariant
|
||||
}
|
||||
|
||||
func TestResolveTypedInputs_NestedInOneOf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
body := "nested variant body\n"
|
||||
if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "")
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
t.Fatalf("resolveTypedInputs: %v", err)
|
||||
}
|
||||
out := &nestedInputArgs{}
|
||||
if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindBuckets: %v", err)
|
||||
}
|
||||
if out.Content.Body == nil {
|
||||
t.Fatal("Content.Body is nil — variant not bound")
|
||||
}
|
||||
if *out.Content.Body != body {
|
||||
t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mount-time validation: enum / input only on string leaves ------------
|
||||
|
||||
type enumOnIntArgs struct {
|
||||
Level int `flag:"level" enum:"1,2,3"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for enum on int field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "enum tag is only supported on string") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type inputOnBoolArgs struct {
|
||||
Flag bool `flag:"flag" input:"file"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_InputOnNonStringErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for input on bool field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "input tag is only supported on string") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownInputSrcArgs struct {
|
||||
Content string `flag:"content" input:"bogus"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_UnknownInputSource(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{}))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown input source")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown input source") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A string-alias enum field (e.g. an argstype primitive) must be accepted.
|
||||
type enumOnStringArgs struct {
|
||||
Priority string `flag:"priority" enum:"low,normal,high"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_EnumOnStringOK(t *testing.T) {
|
||||
specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(specs) != 1 || len(specs[0].EnumValues) != 3 {
|
||||
t.Errorf("enum not parsed: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- enum shell completion + help candidate rendering ---------------------
|
||||
|
||||
func TestRegisterLeaf_EnumCompletion(t *testing.T) {
|
||||
prev := cmdutil.FlagCompletionsEnabled()
|
||||
cmdutil.SetFlagCompletionsEnabled(true)
|
||||
defer cmdutil.SetFlagCompletionsEnabled(prev)
|
||||
|
||||
specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
fn, ok := cmd.GetFlagCompletionFunc("priority")
|
||||
if !ok || fn == nil {
|
||||
t.Fatal("expected a completion func registered for --priority")
|
||||
}
|
||||
vals, _ := fn(cmd, nil, "")
|
||||
want := map[string]bool{"low": true, "normal": true, "high": true}
|
||||
if len(vals) != 3 {
|
||||
t.Fatalf("completion candidates = %v, want low/normal/high", vals)
|
||||
}
|
||||
for _, v := range vals {
|
||||
if !want[v] {
|
||||
t.Errorf("unexpected completion candidate %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_EnumCandidates(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(one of: low|normal|high)") {
|
||||
t.Errorf("help line missing enum candidates: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_EnumAndDefault(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) {
|
||||
t.Errorf("help line missing enum or default: %q", line)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// --- []<named string> and named slice types bind without panicking --------
|
||||
// Regression guard: reflect.Convert([]string -> []myID) panics, so the binder
|
||||
// must build the slice element-by-element via SetString instead.
|
||||
|
||||
type myID string
|
||||
|
||||
type namedElemArgs struct {
|
||||
Xs []myID `flag:"xs"`
|
||||
}
|
||||
|
||||
func TestSliceFlag_NamedElementType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&namedElemArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--xs", "a,b"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &namedElemArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Xs, []myID{"a", "b"}) {
|
||||
t.Errorf("Xs = %#v, want []myID{a b}", out.Xs)
|
||||
}
|
||||
}
|
||||
|
||||
type myIDList []string
|
||||
|
||||
type namedSliceArgs struct {
|
||||
Ids myIDList `flag:"ids"`
|
||||
}
|
||||
|
||||
func TestSliceFlag_NamedSliceType(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&namedSliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--ids", "x,y,z"})
|
||||
out := &namedSliceArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Ids, myIDList{"x", "y", "z"}) {
|
||||
t.Errorf("Ids = %#v, want myIDList{x y z}", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
// --- nil Execute → not mounted (parity with legacy Shortcut) ---------------
|
||||
|
||||
type nilExecArgs struct {
|
||||
Name string `flag:"name"`
|
||||
}
|
||||
|
||||
func TestMountTyped_NilExecuteNotMounted(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*nilExecArgs]{
|
||||
Service: "x", Command: "+noexec", AuthTypes: []string{"user"}, Risk: "read",
|
||||
// Execute intentionally nil — legacy skips mounting such shortcuts.
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
if sub, _, _ := root.Find([]string{"+noexec"}); sub != nil && sub.Name() == "+noexec" {
|
||||
t.Error("nil-Execute typed shortcut must NOT be mounted (parity with legacy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTyped_WithExecuteMounted(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*nilExecArgs]{
|
||||
Service: "x", Command: "+yesexec", AuthTypes: []string{"user"}, Risk: "read",
|
||||
Execute: func(ctx context.Context, args *nilExecArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
if sub, _, _ := root.Find([]string{"+yesexec"}); sub == nil || sub.Name() != "+yesexec" {
|
||||
t.Error("typed shortcut with Execute should be mounted")
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// --- multi-value ([]string) flags -----------------------------------------
|
||||
|
||||
type sliceArgs struct {
|
||||
Ids []string `flag:"ids"` // default → StringSlice (comma-split)
|
||||
Tags []string `flag:"tags" split:"none"` // StringArray (repeatable, no split)
|
||||
}
|
||||
|
||||
func TestSliceFlag_StringSliceDefault(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--ids", "a,b,c"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &sliceArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bind: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.Ids, []string{"a", "b", "c"}) {
|
||||
t.Errorf("Ids = %#v, want [a b c] (comma-split)", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceFlag_StringArrayNoSplit(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
// repeated; a value containing a comma must NOT be split (StringArray)
|
||||
if err := cmd.ParseFlags([]string{"--tags", "a,b", "--tags", "c"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &sliceArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if !reflect.DeepEqual(out.Tags, []string{"a,b", "c"}) {
|
||||
t.Errorf("Tags = %#v, want [\"a,b\" \"c\"] (no split, repeatable)", out.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceFlag_UnsetIsEmpty(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &sliceArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if len(out.Ids) != 0 {
|
||||
t.Errorf("Ids = %#v, want empty when unset", out.Ids)
|
||||
}
|
||||
}
|
||||
|
||||
type sliceGroup struct {
|
||||
Items []string `flag:"items"`
|
||||
Note string `flag:"note"`
|
||||
}
|
||||
|
||||
type groupSliceArgs struct {
|
||||
G sliceGroup
|
||||
}
|
||||
|
||||
func TestSliceFlag_InGroup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&groupSliceArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
_ = registerFlags(cmd, specs)
|
||||
if err := cmd.ParseFlags([]string{"--items", "x,y", "--note", "hi"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
out := &groupSliceArgs{}
|
||||
if err := bindGroups(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(out.G.Items, []string{"x", "y"}) {
|
||||
t.Errorf("G.Items = %#v, want [x y]", out.G.Items)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mount-time validation for slices / split -----------------------------
|
||||
|
||||
type splitOnStringArgs struct {
|
||||
S string `flag:"s" split:"none"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_SplitOnNonSliceErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&splitOnStringArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "split tag is only supported on []string") {
|
||||
t.Fatalf("expected split-on-non-slice error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type intSliceArgs struct {
|
||||
N []int `flag:"n"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_NonStringSliceErrors(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&intSliceArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "only []string slices are supported") {
|
||||
t.Fatalf("expected []int error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type unknownSplitArgs struct {
|
||||
S []string `flag:"s" split:"bogus"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_UnknownSplitMode(t *testing.T) {
|
||||
_, err := walkArgs(reflect.TypeOf(&unknownSplitArgs{}))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown split mode") {
|
||||
t.Fatalf("expected unknown split mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-flag hidden ------------------------------------------------------
|
||||
|
||||
type hiddenArgs struct {
|
||||
Visible string `flag:"visible"`
|
||||
Secret string `flag:"secret" hidden:"true"`
|
||||
}
|
||||
|
||||
func TestHiddenFlag_RegisteredButHidden(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
f := cmd.Flags().Lookup("secret")
|
||||
if f == nil {
|
||||
t.Fatal("secret flag not registered")
|
||||
}
|
||||
if !f.Hidden {
|
||||
t.Error("secret flag should be marked hidden")
|
||||
}
|
||||
// hidden does not mean disabled — it still binds.
|
||||
_ = cmd.ParseFlags([]string{"--secret", "shh", "--visible", "v"})
|
||||
out := &hiddenArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Secret != "shh" {
|
||||
t.Errorf("Secret = %q, want shh (hidden but functional)", out.Secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenFlag_SkippedInHelp(t *testing.T) {
|
||||
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
_ = registerFlags(cmd, specs)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
buildTypedHelp(specs, nil)(cmd, nil)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "--visible") {
|
||||
t.Errorf("help should show --visible:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "--secret") {
|
||||
t.Errorf("help must NOT show hidden --secret:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- @file / stdin help hint ----------------------------------------------
|
||||
|
||||
func TestFormatLeafLine_InputHintBoth(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "content", Description: "the content", Input: []string{File, Stdin}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(supports @file, - for stdin)") {
|
||||
t.Errorf("missing input hint: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLeafLine_InputHintFileOnly(t *testing.T) {
|
||||
s := fieldSpec{FlagName: "content", Description: "c", Input: []string{File}}
|
||||
line := formatLeafLine(" ", s)
|
||||
if !strings.Contains(line, "(supports @file)") || strings.Contains(line, "stdin") {
|
||||
t.Errorf("file-only hint wrong: %q", line)
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type simpleArgs struct {
|
||||
Name string `flag:"name" desc:"a name"`
|
||||
Count int `flag:"count" default:"3"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_Simple(t *testing.T) {
|
||||
specs, err := walkArgs(reflect.TypeOf(&simpleArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
if len(specs) != 2 {
|
||||
t.Fatalf("expected 2 field specs, got %d", len(specs))
|
||||
}
|
||||
if specs[0].FlagName != "name" || specs[1].FlagName != "count" {
|
||||
t.Errorf("flag names: %+v", specs)
|
||||
}
|
||||
}
|
||||
|
||||
type dupTagArgs struct {
|
||||
A string `flag:"x"`
|
||||
B string `flag:"x"`
|
||||
}
|
||||
|
||||
func TestWalkArgs_DuplicateTagPanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("expected panic for duplicate flag tag")
|
||||
}
|
||||
}()
|
||||
_, _ = walkArgs(reflect.TypeOf(&dupTagArgs{}))
|
||||
}
|
||||
|
||||
type bindArgs struct {
|
||||
Name string `flag:"name"`
|
||||
Count int `flag:"count" default:"7"`
|
||||
}
|
||||
|
||||
func TestRegisterAndBind_StringInt(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
t.Fatalf("registerFlags: %v", err)
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--name", "alice", "--count", "12"})
|
||||
out := &bindArgs{}
|
||||
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Fatalf("bindFlags: %v", err)
|
||||
}
|
||||
if out.Name != "alice" {
|
||||
t.Errorf("Name = %q, want alice", out.Name)
|
||||
}
|
||||
if out.Count != 12 {
|
||||
t.Errorf("Count = %d, want 12", out.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_DefaultApplies(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &bindArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if out.Count != 7 {
|
||||
t.Errorf("default not applied: Count = %d, want 7", out.Count)
|
||||
}
|
||||
}
|
||||
|
||||
type withValidatable struct {
|
||||
ID dummyValidatable `flag:"id"`
|
||||
}
|
||||
|
||||
func TestRunValidateValue_CallsValidatable(t *testing.T) {
|
||||
v := &withValidatable{}
|
||||
specs, _ := walkArgs(reflect.TypeOf(v))
|
||||
rt := &RuntimeContext{}
|
||||
if err := runValidateValue(rt, reflect.ValueOf(v).Elem(), specs); err != nil {
|
||||
t.Errorf("runValidateValue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type oneOfArgs struct {
|
||||
A *string `flag:"a"`
|
||||
B *string `flag:"b"`
|
||||
}
|
||||
|
||||
func (oneOfArgs) OneOf() {}
|
||||
|
||||
type bucketArgs struct {
|
||||
Bucket oneOfArgs
|
||||
}
|
||||
|
||||
func TestFrameworkRules_OneOfMissing(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &bucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_missing error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMissing {
|
||||
t.Errorf("Subtype = %q", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameworkRules_OneOfMultiple(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--a", "1", "--b", "2"})
|
||||
out := &bucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_multiple error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
|
||||
t.Errorf("Subtype = %q", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// --- top-level group binding (bindGroups) ---
|
||||
|
||||
type dateRange struct {
|
||||
From string `flag:"from"`
|
||||
To string `flag:"to"`
|
||||
}
|
||||
type groupValueArgs struct {
|
||||
Range dateRange
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelValueGroup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupValueArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--from", "2026-01-01", "--to", "2026-12-31"})
|
||||
out := &groupValueArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Range.From != "2026-01-01" || out.Range.To != "2026-12-31" {
|
||||
t.Errorf("Range = %+v", out.Range)
|
||||
}
|
||||
}
|
||||
|
||||
type defaultedGroup struct {
|
||||
Port string `flag:"port" default:"8080"`
|
||||
Host string `flag:"host" default:"localhost"`
|
||||
}
|
||||
type groupDefaultArgs struct {
|
||||
Conf defaultedGroup
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelValueGroup_AppliesDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupDefaultArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &groupDefaultArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Conf.Port != "8080" || out.Conf.Host != "localhost" {
|
||||
t.Errorf("defaults not applied: Conf = %+v", out.Conf)
|
||||
}
|
||||
}
|
||||
|
||||
type proxyConf struct {
|
||||
Host string `flag:"proxy-host"`
|
||||
Port string `flag:"proxy-port"`
|
||||
}
|
||||
type groupPtrArgs struct {
|
||||
Proxy *proxyConf
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelPtrGroup_AllocatedWhenChanged(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--proxy-host", "p.example.com"})
|
||||
out := &groupPtrArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Proxy == nil {
|
||||
t.Fatal("expected Proxy to be allocated when an inner flag was changed")
|
||||
}
|
||||
if out.Proxy.Host != "p.example.com" {
|
||||
t.Errorf("Proxy.Host = %q", out.Proxy.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindGroups_TopLevelPtrGroup_NilWhenAbsent(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags(nil)
|
||||
out := &groupPtrArgs{}
|
||||
argsVal := reflect.ValueOf(out).Elem()
|
||||
_ = bindFlags(cmd, argsVal, specs)
|
||||
if err := bindGroups(cmd, argsVal, specs); err != nil {
|
||||
t.Fatalf("bindGroups: %v", err)
|
||||
}
|
||||
if out.Proxy != nil {
|
||||
t.Errorf("expected Proxy nil when no inner flag set, got %+v", out.Proxy)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OneOf with a nested group variant (no oneof_trigger; any inner flag
|
||||
// counts as attempting that variant) ---
|
||||
|
||||
type vidGroup struct {
|
||||
File string `flag:"vid-file"`
|
||||
Cover string `flag:"vid-cover"`
|
||||
}
|
||||
type contentBucket struct {
|
||||
Text *string `flag:"ct"`
|
||||
Video *vidGroup
|
||||
}
|
||||
|
||||
func (contentBucket) OneOf() {}
|
||||
|
||||
type contentBucketArgs struct {
|
||||
Bucket contentBucket
|
||||
}
|
||||
|
||||
func TestCheckOneOf_GroupCompanionAloneTriggersVariant(t *testing.T) {
|
||||
// Companion --vid-cover alone (no --vid-file) should count as attempting
|
||||
// the Video variant; OneOf check passes (1 variant attempted) and the
|
||||
// group completeness check then surfaces shortcut_group_incomplete with
|
||||
// the specific missing flag, not a misleading shortcut_oneof_missing.
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected group_incomplete error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
|
||||
t.Errorf("Subtype = %q, want shortcut_group_incomplete", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneOf_GroupVariantBothFieldsOK(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--vid-file", "v.mp4", "--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
|
||||
t.Errorf("expected OK, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOneOf_SimpleAndGroupBothAttempted_Multiple(t *testing.T) {
|
||||
// Text variant set AND Video group's companion set → both variants are
|
||||
// attempted; expect shortcut_oneof_multiple.
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
|
||||
_ = registerFlags(cmd, specs)
|
||||
_ = cmd.ParseFlags([]string{"--ct", "hi", "--vid-cover", "c.png"})
|
||||
out := &contentBucketArgs{}
|
||||
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
|
||||
if err == nil {
|
||||
t.Fatal("expected oneof_multiple error")
|
||||
}
|
||||
ve := mustValidationError(t, err)
|
||||
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
|
||||
t.Errorf("Subtype = %q, want shortcut_oneof_multiple", ve.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func mustValidationError(t *testing.T, err error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ShortcutDescriptor exposes the read-only metadata that auth, scope-hint,
|
||||
// shortcuts.json generation, and diagnose-scope consumers need. Both legacy
|
||||
// Shortcut and the new TypedShortcut[T] satisfy it (see types.go and
|
||||
// typed_shortcut.go for the implementations).
|
||||
type ShortcutDescriptor interface {
|
||||
GetService() string
|
||||
GetCommand() string
|
||||
GetDescription() string
|
||||
GetAuthTypes() []string
|
||||
GetRisk() string
|
||||
ScopesForIdentity(identity string) []string
|
||||
ConditionalScopesForIdentity(identity string) []string
|
||||
DeclaredScopesForIdentity(identity string) []string
|
||||
}
|
||||
|
||||
// Mountable is the registration contract for register.go. ShortcutDescriptor
|
||||
// is embedded so a single interface slice covers both metadata reads and
|
||||
// cobra mounting.
|
||||
type Mountable interface {
|
||||
ShortcutDescriptor
|
||||
MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory)
|
||||
}
|
||||
|
||||
// OneOfMarker is the opt-in marker for "exactly one variant" sub-structs.
|
||||
// Variant fields must be pointers; the binder treats a non-nil pointer as
|
||||
// "this variant was selected by user-set trigger flag". See spec §"OneOf
|
||||
// trigger semantics" for the trigger rule.
|
||||
type OneOfMarker interface {
|
||||
OneOf()
|
||||
}
|
||||
|
||||
// Validatable is implemented by typed primitives (and may be by sub-structs
|
||||
// that validate a composite value, e.g. a raw JSON body) that own their
|
||||
// format check. The binder calls
|
||||
// ValidateValue per field after Normalize. Returning an error must produce
|
||||
// a *errs.ValidationError so the stderr envelope carries type/subtype/param.
|
||||
type Validatable interface {
|
||||
ValidateValue(rt *RuntimeContext, flagName string) error
|
||||
}
|
||||
|
||||
// Normalizable[T] is implemented by typed primitives that canonicalize raw
|
||||
// user input (e.g. SpreadsheetRef extracting token from URL). The binder
|
||||
// calls Normalize before ValidateValue and writes the canonical value back.
|
||||
// hints, if any, are written to stderr once during the Validate phase.
|
||||
//
|
||||
// Local-path Normalize MUST NOT emit absolute paths in hints (log safety).
|
||||
type Normalizable[T any] interface {
|
||||
Normalize(ctx context.Context, raw string) (value T, hints []string, err error)
|
||||
}
|
||||
|
||||
// ArgsValidator is the cross-field escape hatch. An Args struct may opt in
|
||||
// by adding this method; the binder calls it after framework-derived
|
||||
// validation (required / enum / OneOf / group), before the user-defined
|
||||
// TypedShortcut.Validate hook.
|
||||
type ArgsValidator interface {
|
||||
Validate(ctx context.Context, rt *RuntimeContext) error
|
||||
}
|
||||
|
||||
// HelpExample appears in TypedShortcut.Examples and is rendered by
|
||||
// typed_help under an "EXAMPLES:" section.
|
||||
type HelpExample struct {
|
||||
Title string
|
||||
Cmd string
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dummyOneOf demonstrates how a sub-struct opts into OneOfMarker by adding
|
||||
// the empty OneOf() method.
|
||||
type dummyOneOf struct{ A, B *string }
|
||||
|
||||
func (dummyOneOf) OneOf() {}
|
||||
|
||||
func TestOneOfMarker_Detection(t *testing.T) {
|
||||
var v interface{} = dummyOneOf{}
|
||||
if _, ok := v.(OneOfMarker); !ok {
|
||||
t.Fatal("dummyOneOf should satisfy OneOfMarker")
|
||||
}
|
||||
}
|
||||
|
||||
// dummyValidatable implements Validatable.
|
||||
type dummyValidatable struct{}
|
||||
|
||||
func (dummyValidatable) ValidateValue(rt *RuntimeContext, flag string) error { return nil }
|
||||
|
||||
func TestValidatable_InterfaceShape(t *testing.T) {
|
||||
var _ Validatable = dummyValidatable{}
|
||||
}
|
||||
|
||||
// dummyNormalizable implements Normalizable[string].
|
||||
type dummyNormalizable struct{}
|
||||
|
||||
func (dummyNormalizable) Normalize(ctx context.Context, raw string) (string, []string, error) {
|
||||
return raw, nil, nil
|
||||
}
|
||||
|
||||
func TestNormalizable_InterfaceShape(t *testing.T) {
|
||||
var n Normalizable[string] = dummyNormalizable{}
|
||||
got, hints, err := n.Normalize(context.Background(), "x")
|
||||
if err != nil || got != "x" || hints != nil {
|
||||
t.Errorf("dummy Normalize round-trip: got=%q hints=%v err=%v", got, hints, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpExample_Fields(t *testing.T) {
|
||||
ex := HelpExample{Title: "send text", Cmd: "--chat-id oc_x --text hi"}
|
||||
if ex.Title != "send text" || ex.Cmd != "--chat-id oc_x --text hi" {
|
||||
t.Errorf("HelpExample: got %+v", ex)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,6 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
typedArgs any // per-run typed Args, set by binder; consumed by DryRun/Execute
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -1018,69 +1017,56 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil {
|
||||
return err
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single
|
||||
// string flag. sources lists the accepted inputs (File / Stdin); empty sources
|
||||
// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags
|
||||
// of one invocation so stdin (-) is consumed by at most one flag. Both the
|
||||
// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call
|
||||
// through here, so @file / stdin behaves identically on TypedShortcut[T].
|
||||
func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := rctx.Cmd.Flags().GetString(name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", name)
|
||||
}
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(sources, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", name)
|
||||
}
|
||||
if *stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", name)
|
||||
}
|
||||
*stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", name, err)
|
||||
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
|
||||
}
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
rctx.Cmd.Flags().Set(name, string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// escape: @@ → literal @
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @
|
||||
return nil
|
||||
}
|
||||
// stdin: -
|
||||
if raw == "-" {
|
||||
if !slices.Contains(fl.Input, Stdin) {
|
||||
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
}
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(sources, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", name)
|
||||
// escape: @@ → literal @
|
||||
if strings.HasPrefix(raw, "@@") {
|
||||
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", name)
|
||||
|
||||
// file: @path
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
if !slices.Contains(fl.Input, File) {
|
||||
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", fl.Name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
continue
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: %v", name, err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(name, string(data))
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1190,6 +1176,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
@@ -1197,12 +1186,3 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
// TypedArgs returns the per-run Args struct populated by the binder. Returns
|
||||
// nil for runs that didn't go through TypedShortcut[T]. Callers should
|
||||
// type-assert to the concrete *Args type.
|
||||
func (ctx *RuntimeContext) TypedArgs() any { return ctx.typedArgs }
|
||||
|
||||
// SetTypedArgs stores the per-run Args struct. Called once by the binder
|
||||
// after bind + Normalize complete. Must not be called by user hooks.
|
||||
func (ctx *RuntimeContext) SetTypedArgs(v any) { ctx.typedArgs = v }
|
||||
|
||||
@@ -56,17 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_TypedArgs_RoundTrip(t *testing.T) {
|
||||
type sample struct{ X int }
|
||||
rt := &RuntimeContext{}
|
||||
if got := rt.TypedArgs(); got != nil {
|
||||
t.Fatalf("fresh RuntimeContext.TypedArgs() = %v, want nil", got)
|
||||
}
|
||||
in := &sample{X: 42}
|
||||
rt.SetTypedArgs(in)
|
||||
out, ok := rt.TypedArgs().(*sample)
|
||||
if !ok || out != in {
|
||||
t.Errorf("round-trip failed: got %v ok=%v", rt.TypedArgs(), ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,3 +96,76 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
t.Fatal("did not expect completion func for --format when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+read",
|
||||
Description: "test read",
|
||||
HasFormat: true,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+read"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if flag := cmd.Flags().Lookup("json"); flag == nil {
|
||||
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+update",
|
||||
Description: "test update",
|
||||
HasFormat: true,
|
||||
Flags: []Flag{
|
||||
{Name: "json", Desc: "body JSON object", Required: true},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+update"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
// --json flag exists (from custom Flags), but should be the string type, not bool.
|
||||
flag := cmd.Flags().Lookup("json")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --json flag from custom Flags")
|
||||
}
|
||||
if flag.DefValue != "" {
|
||||
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "test",
|
||||
Command: "+write",
|
||||
Description: "test write",
|
||||
HasFormat: false,
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+write"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
// --format is now registered for all shortcuts (regardless of HasFormat),
|
||||
// so --json should also be present.
|
||||
if flag := cmd.Flags().Lookup("json"); flag == nil {
|
||||
t.Fatal("expected --json flag to be registered even when HasFormat is false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// buildTypedHelp returns a cobra.HelpFunc that renders typed shortcuts in
|
||||
// sections (CHOOSE ONE <FIELD> / OPTIONAL / EXAMPLES / GLOBAL FLAGS / Risk: /
|
||||
// Tips:). Cobra's default HelpFunc is preserved for all non-typed commands;
|
||||
// we only override per-command via cmd.SetHelpFunc.
|
||||
//
|
||||
// Section titles use the Args struct's field name (e.g. "TARGET", "CONTENT"),
|
||||
// not the inner Go type name behind the field, so the help mirrors the
|
||||
// user-visible variable name rather than an implementation detail.
|
||||
func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Command, []string) {
|
||||
return func(cmd *cobra.Command, _ []string) {
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintf(w, "%s — %s\n\n", cmd.Use, cmd.Short)
|
||||
rendered := map[string]struct{}{}
|
||||
renderOneOfSections(w, specs, rendered)
|
||||
renderRequiredSection(w, specs, rendered)
|
||||
renderOptionalSection(w, specs, rendered)
|
||||
renderExamples(w, examples)
|
||||
renderGlobalFlags(w, cmd, rendered)
|
||||
if r, ok := cmdutil.GetRisk(cmd); ok && r != "" {
|
||||
fmt.Fprintf(w, "Risk: %s\n\n", r)
|
||||
}
|
||||
for _, tip := range cmdutil.GetTips(cmd) {
|
||||
fmt.Fprintf(w, "Tips: %s\n", tip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatLeafLine renders one leaf flag line — "--name description" with an
|
||||
// optional `(default "x")` suffix when the field declares a default. Reused by
|
||||
// every section renderer so REQUIRED / OPTIONAL / CHOOSE ONE all surface the
|
||||
// same information density as cobra's legacy default help.
|
||||
func formatLeafLine(indent string, s fieldSpec) string {
|
||||
line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description)
|
||||
if len(s.EnumValues) > 0 {
|
||||
line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|"))
|
||||
}
|
||||
if len(s.Input) > 0 {
|
||||
var srcs []string
|
||||
for _, src := range s.Input {
|
||||
switch src {
|
||||
case File:
|
||||
srcs = append(srcs, "@file")
|
||||
case Stdin:
|
||||
srcs = append(srcs, "- for stdin")
|
||||
}
|
||||
}
|
||||
line += fmt.Sprintf(" (supports %s)", strings.Join(srcs, ", "))
|
||||
}
|
||||
if s.DefaultValue != "" {
|
||||
line += fmt.Sprintf(" (default %q)", s.DefaultValue)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// renderOneOfSections walks each OneOf bucket and prints "CHOOSE ONE <FIELD>:"
|
||||
// followed by every flag inside the bucket — including flags inside nested
|
||||
// groups (a paired group's companion flag under its trigger) and nested
|
||||
// raw-content variants (a raw-JSON variant's body + msg-type flags).
|
||||
func renderOneOfSections(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
for _, s := range specs {
|
||||
if !s.IsOneOfBkt {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "CHOOSE ONE %s:\n", strings.ToUpper(s.GoFieldName))
|
||||
inner, _ := walkArgs(reflect.PointerTo(s.StructType))
|
||||
renderFlagsInBucket(w, inner, " ", rendered)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// renderFlagsInBucket renders bucket inner flags, recursing into nested group
|
||||
// / OneOf sub-structs. The structure is FLATTENED — every leaf flag of an
|
||||
// inner variant renders at the parent's indent. The framework treats any
|
||||
// inner flag of a group variant equally (providing any of them counts as
|
||||
// selecting that variant), so the help no longer visually distinguishes a
|
||||
// "trigger" from a "companion".
|
||||
func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered map[string]struct{}) {
|
||||
for _, child := range specs {
|
||||
if child.IsGroup || child.IsOneOfBkt {
|
||||
inner, _ := walkArgs(reflect.PointerTo(child.StructType))
|
||||
for _, leaf := range inner {
|
||||
if leaf.IsGroup || leaf.IsOneOfBkt {
|
||||
grand, _ := walkArgs(reflect.PointerTo(leaf.StructType))
|
||||
renderFlagsInBucket(w, grand, indent+" ", rendered)
|
||||
continue
|
||||
}
|
||||
if leaf.FlagName == "" || leaf.Hidden {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(indent, leaf))
|
||||
rendered[leaf.FlagName] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if child.FlagName == "" || child.Hidden {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(indent, child))
|
||||
rendered[child.FlagName] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// renderRequiredSection prints top-level leaf flags that carry the `required`
|
||||
// tag under a "REQUIRED:" header so users can see at a glance which flags they
|
||||
// must supply. Sub-struct fields are skipped here because they're surfaced
|
||||
// under the CHOOSE ONE sections above.
|
||||
func renderRequiredSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
anyFlag := false
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || !s.Required || s.Hidden {
|
||||
continue
|
||||
}
|
||||
if !anyFlag {
|
||||
fmt.Fprintln(w, "REQUIRED:")
|
||||
anyFlag = true
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(" ", s))
|
||||
rendered[s.FlagName] = struct{}{}
|
||||
}
|
||||
if anyFlag {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
// renderOptionalSection prints top-level leaf flags that don't belong to any
|
||||
// OneOf bucket and aren't tagged required (those are handled by
|
||||
// renderRequiredSection above). Sub-struct fields are skipped here because
|
||||
// they live under CHOOSE ONE sections above.
|
||||
func renderOptionalSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
|
||||
anyFlag := false
|
||||
for _, s := range specs {
|
||||
if s.IsOneOfBkt || s.IsGroup {
|
||||
continue
|
||||
}
|
||||
if s.FlagName == "" || s.Required || s.Hidden {
|
||||
continue
|
||||
}
|
||||
if !anyFlag {
|
||||
fmt.Fprintln(w, "OPTIONAL:")
|
||||
anyFlag = true
|
||||
}
|
||||
fmt.Fprintln(w, formatLeafLine(" ", s))
|
||||
rendered[s.FlagName] = struct{}{}
|
||||
}
|
||||
if anyFlag {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func renderExamples(w io.Writer, examples []HelpExample) {
|
||||
if len(examples) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "EXAMPLES:")
|
||||
for _, e := range examples {
|
||||
fmt.Fprintf(w, " %-20s %s\n", e.Title+":", e.Cmd)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// renderGlobalFlags prints framework-injected and cobra-inherited flags
|
||||
// (--as / --dry-run / --jq / --format / -h / --help) that the typed Args
|
||||
// struct does NOT define. The `rendered` set captures every flag name we
|
||||
// already emitted under CHOOSE ONE / OPTIONAL — anything else surfaced by
|
||||
// cmd.Flags() or cmd.InheritedFlags() falls under GLOBAL FLAGS.
|
||||
func renderGlobalFlags(w io.Writer, cmd *cobra.Command, rendered map[string]struct{}) {
|
||||
type globalFlag struct {
|
||||
Name string
|
||||
Shorthand string
|
||||
Usage string
|
||||
}
|
||||
var globals []globalFlag
|
||||
seen := map[string]bool{}
|
||||
collect := func(fs *pflag.FlagSet) {
|
||||
fs.VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
if _, alreadyRendered := rendered[f.Name]; alreadyRendered {
|
||||
return
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
globals = append(globals, globalFlag{Name: f.Name, Shorthand: f.Shorthand, Usage: f.Usage})
|
||||
})
|
||||
}
|
||||
collect(cmd.Flags())
|
||||
collect(cmd.InheritedFlags())
|
||||
// Cobra auto-injects --help on every command, but it does not appear in
|
||||
// LocalFlags or InheritedFlags until the command has been resolved at
|
||||
// invocation time. Ensure it is always documented.
|
||||
if !seen["help"] {
|
||||
globals = append(globals, globalFlag{Name: "help", Shorthand: "h", Usage: "show help"})
|
||||
}
|
||||
if len(globals) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "GLOBAL FLAGS:")
|
||||
for _, g := range globals {
|
||||
if g.Shorthand != "" {
|
||||
fmt.Fprintf(w, " -%s, --%s %s\n", g.Shorthand, g.Name, g.Usage)
|
||||
} else {
|
||||
fmt.Fprintf(w, " --%s %s\n", g.Name, g.Usage)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
type helpDemoTarget struct {
|
||||
Chat *string `flag:"chat-id"`
|
||||
User *string `flag:"user-id"`
|
||||
}
|
||||
|
||||
func (helpDemoTarget) OneOf() {}
|
||||
|
||||
type helpDemoArgs struct {
|
||||
Target helpDemoTarget
|
||||
Idemp string `flag:"idempotency-key"`
|
||||
}
|
||||
|
||||
func TestTypedHelp_RendersSections(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
cmdutil.SetTips(cmd, []string{"call carefully"})
|
||||
specs, err := walkArgs(reflect.TypeOf(&helpDemoArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, []HelpExample{{Title: "demo", Cmd: "--chat-id oc_x"}}))
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
out := buf.String()
|
||||
for _, section := range []string{"CHOOSE ONE", "OPTIONAL", "EXAMPLES", "Risk:", "Tips:"} {
|
||||
if !strings.Contains(out, section) {
|
||||
t.Errorf("help missing %q section; got:\n%s", section, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helpReqDefaultsArgs covers two cases the renderer previously botched:
|
||||
//
|
||||
// 1. A required flag (--limit) should land under "REQUIRED:" instead of
|
||||
// being silently glued into "OPTIONAL:".
|
||||
// 2. A flag with default (--page-size) should display (default "20").
|
||||
type helpReqDefaultsArgs struct {
|
||||
Limit string `flag:"limit" required:"true" desc:"max items"`
|
||||
PageSize string `flag:"page-size" default:"20" desc:"page size"`
|
||||
Verbose string `flag:"verbose" desc:"output mode"`
|
||||
}
|
||||
|
||||
func TestTypedHelp_RequiredSectionAndDefaults(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
|
||||
specs, err := walkArgs(reflect.TypeOf(&helpReqDefaultsArgs{}))
|
||||
if err != nil {
|
||||
t.Fatalf("walkArgs: %v", err)
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, nil))
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.HelpFunc()(cmd, nil)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "REQUIRED:") {
|
||||
t.Errorf("expected REQUIRED: section, got:\n%s", out)
|
||||
}
|
||||
// --limit must be under REQUIRED, NOT under OPTIONAL.
|
||||
reqIdx := strings.Index(out, "REQUIRED:")
|
||||
optIdx := strings.Index(out, "OPTIONAL:")
|
||||
limitIdx := strings.Index(out, "--limit")
|
||||
if reqIdx < 0 || optIdx < 0 || limitIdx < 0 {
|
||||
t.Fatalf("layout markers missing: REQUIRED@%d OPTIONAL@%d --limit@%d\n%s", reqIdx, optIdx, limitIdx, out)
|
||||
}
|
||||
if !(reqIdx < limitIdx && limitIdx < optIdx) {
|
||||
t.Errorf("--limit should appear under REQUIRED before OPTIONAL; got:\n%s", out)
|
||||
}
|
||||
|
||||
// Default value must be surfaced for --page-size.
|
||||
if !strings.Contains(out, `(default "20")`) {
|
||||
t.Errorf("expected default value rendering for --page-size; got:\n%s", out)
|
||||
}
|
||||
|
||||
// Plain flag without default or required tag must NOT carry a (default …) suffix.
|
||||
verboseLine := ""
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "--verbose") {
|
||||
verboseLine = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if verboseLine == "" {
|
||||
t.Fatalf("expected --verbose flag to be rendered; got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(verboseLine, "(default") {
|
||||
t.Errorf("--verbose has no default, should not carry default suffix: %q", verboseLine)
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TypedShortcut is the generic counterpart of the legacy Shortcut struct.
|
||||
// Args must be a pointer to a struct (e.g. *MyArgs). Mounting reflects the
|
||||
// struct, registers cobra flags, then delegates to a synthesized Shortcut
|
||||
// shell so the existing run pipeline (identity / scopes / @file / stdin /
|
||||
// jq / dry-run / high-risk gate) is reused verbatim.
|
||||
type TypedShortcut[T any] struct {
|
||||
Service, Command, Description string
|
||||
Risk string
|
||||
Scopes, UserScopes, BotScopes []string
|
||||
ConditionalScopes []string
|
||||
ConditionalUserScopes []string
|
||||
ConditionalBotScopes []string
|
||||
AuthTypes []string
|
||||
HasFormat bool
|
||||
Tips []string
|
||||
Hidden bool
|
||||
|
||||
Examples []HelpExample
|
||||
|
||||
DryRun func(ctx context.Context, args T, rt *RuntimeContext) *DryRunAPI
|
||||
Validate func(ctx context.Context, args T, rt *RuntimeContext) error
|
||||
Execute func(ctx context.Context, args T, rt *RuntimeContext) error
|
||||
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) GetService() string { return s.Service }
|
||||
func (s TypedShortcut[T]) GetCommand() string { return s.Command }
|
||||
func (s TypedShortcut[T]) GetDescription() string { return s.Description }
|
||||
func (s TypedShortcut[T]) GetAuthTypes() []string { return s.AuthTypes }
|
||||
func (s TypedShortcut[T]) GetRisk() string { return s.Risk }
|
||||
|
||||
func (s TypedShortcut[T]) ScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.UserScopes) > 0 {
|
||||
return s.UserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.BotScopes) > 0 {
|
||||
return s.BotScopes
|
||||
}
|
||||
}
|
||||
return s.Scopes
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) ConditionalScopesForIdentity(identity string) []string {
|
||||
switch identity {
|
||||
case "user":
|
||||
if len(s.ConditionalUserScopes) > 0 {
|
||||
return s.ConditionalUserScopes
|
||||
}
|
||||
case "bot":
|
||||
if len(s.ConditionalBotScopes) > 0 {
|
||||
return s.ConditionalBotScopes
|
||||
}
|
||||
}
|
||||
return s.ConditionalScopes
|
||||
}
|
||||
|
||||
func (s TypedShortcut[T]) DeclaredScopesForIdentity(identity string) []string {
|
||||
base := s.ScopesForIdentity(identity)
|
||||
extra := s.ConditionalScopesForIdentity(identity)
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
seen := map[string]struct{}{}
|
||||
for _, scope := range append(base, extra...) {
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
out = append(out, scope)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Mount registers the typed shortcut on a parent command, mirroring the legacy
|
||||
// Shortcut.Mount API so migrating common.Shortcut → common.TypedShortcut[T]
|
||||
// does not force existing callers (or their tests) to also rewrite the Mount
|
||||
// call site. Delegates to MountWithContext with a background context.
|
||||
func (s TypedShortcut[T]) Mount(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
s.MountWithContext(context.Background(), parent, f)
|
||||
}
|
||||
|
||||
// MountWithContext is implemented in Task 16's adapter section.
|
||||
func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
|
||||
mountTyped[T](ctx, parent, f, s)
|
||||
}
|
||||
|
||||
// mountTyped synthesizes a legacy *Shortcut shell that delegates back to the
|
||||
// typed hooks. The shell's Validate/DryRun/Execute closures read or write
|
||||
// the per-run typed args via RuntimeContext.{TypedArgs,SetTypedArgs}.
|
||||
//
|
||||
// Pipeline order inside the shell (matches runShortcut at runner.go:748):
|
||||
//
|
||||
// 1. identity / scopes / runtime — handled by Shortcut.runShortcut
|
||||
// 2. validateEnumFlags — Shortcut machinery
|
||||
// 3. resolveInputFlags — @file / stdin (legacy shell only; the
|
||||
// synthesized shell's Flags slice is empty, so this is a no-op for typed —
|
||||
// typed flags declare inputs via the `input` tag, resolved in step 5)
|
||||
// 4. ValidateJqFlags — --jq
|
||||
// 5. shell.Validate — resolveTypedInputs (@file / stdin for
|
||||
// `input`-tagged flags), binds T, runs Normalize / ValidateValue /
|
||||
// framework rules / ArgsValidator / user-typed Validate
|
||||
// 6. --dry-run gate — shell.DryRun reads typed args from rt
|
||||
// 7. high-risk-write confirmation — when Risk == "high-risk-write"
|
||||
// 8. shell.Execute — reads typed args from rt
|
||||
func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, s TypedShortcut[T]) {
|
||||
// Mirror legacy Shortcut.MountWithContext: a shortcut with no Execute is
|
||||
// not a runnable command, so it is not mounted at all (rather than mounted
|
||||
// and erroring at invocation time). Keeps the command tree identical to
|
||||
// legacy after migration.
|
||||
if s.Execute == nil {
|
||||
return
|
||||
}
|
||||
var zero T
|
||||
argsType := reflect.TypeOf(zero)
|
||||
if argsType == nil || argsType.Kind() != reflect.Ptr {
|
||||
panic("TypedShortcut[T]: T must be a pointer to a struct, got " + fmt.Sprintf("%T", zero))
|
||||
}
|
||||
specs, err := walkArgs(argsType)
|
||||
if err != nil {
|
||||
panic("TypedShortcut[T].Mount: " + err.Error())
|
||||
}
|
||||
|
||||
shell := Shortcut{
|
||||
Service: s.Service,
|
||||
Command: s.Command,
|
||||
Description: s.Description,
|
||||
Risk: s.Risk,
|
||||
Scopes: s.Scopes,
|
||||
UserScopes: s.UserScopes,
|
||||
BotScopes: s.BotScopes,
|
||||
ConditionalScopes: s.ConditionalScopes,
|
||||
ConditionalUserScopes: s.ConditionalUserScopes,
|
||||
ConditionalBotScopes: s.ConditionalBotScopes,
|
||||
AuthTypes: s.AuthTypes,
|
||||
HasFormat: s.HasFormat,
|
||||
Tips: s.Tips,
|
||||
Hidden: s.Hidden,
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
if err := registerFlags(cmd, specs); err != nil {
|
||||
panic("TypedShortcut[T] registerFlags: " + err.Error())
|
||||
}
|
||||
cmd.SetHelpFunc(buildTypedHelp(specs, s.Examples))
|
||||
if s.PostMount != nil {
|
||||
s.PostMount(cmd)
|
||||
}
|
||||
},
|
||||
Validate: func(c context.Context, rt *RuntimeContext) error {
|
||||
// @file / stdin resolution for flags that declared an `input` tag.
|
||||
// Runs before bindFlags so the resolved file/stdin content is what
|
||||
// gets bound into the Args struct.
|
||||
if err := resolveTypedInputs(rt, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
args := reflect.New(argsType.Elem()).Interface().(T)
|
||||
argsVal := reflect.ValueOf(args).Elem()
|
||||
if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindBuckets(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindGroups(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runNormalize(c, rt, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runValidateValue(rt, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runFrameworkRules(rt.Cmd, argsVal, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
if av, ok := any(args).(ArgsValidator); ok {
|
||||
if err := av.Validate(c, rt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rt.SetTypedArgs(args)
|
||||
if s.Validate != nil {
|
||||
return s.Validate(c, args, rt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(c context.Context, rt *RuntimeContext) error {
|
||||
if s.Execute == nil {
|
||||
return &errs.InternalError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Message: "shortcut " + s.Service + " " + s.Command + " has no Execute handler",
|
||||
},
|
||||
}
|
||||
}
|
||||
args, _ := rt.TypedArgs().(T)
|
||||
return s.Execute(c, args, rt)
|
||||
},
|
||||
}
|
||||
if s.DryRun != nil {
|
||||
shell.DryRun = func(c context.Context, rt *RuntimeContext) *DryRunAPI {
|
||||
args, _ := rt.TypedArgs().(T)
|
||||
return s.DryRun(c, args, rt)
|
||||
}
|
||||
}
|
||||
shell.MountWithContext(ctx, parent, f)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
type stubArgs struct{}
|
||||
|
||||
func newTypedFixture() TypedShortcut[*stubArgs] {
|
||||
return TypedShortcut[*stubArgs]{
|
||||
Service: "im",
|
||||
Command: "+demo",
|
||||
Description: "demo",
|
||||
AuthTypes: []string{"user"},
|
||||
Risk: "write",
|
||||
Scopes: []string{"x"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_Descriptors(t *testing.T) {
|
||||
ts := newTypedFixture()
|
||||
if ts.GetService() != "im" {
|
||||
t.Errorf("GetService=%q", ts.GetService())
|
||||
}
|
||||
if ts.GetCommand() != "+demo" {
|
||||
t.Errorf("GetCommand=%q", ts.GetCommand())
|
||||
}
|
||||
if ts.GetDescription() != "demo" {
|
||||
t.Errorf("GetDescription=%q", ts.GetDescription())
|
||||
}
|
||||
if ts.GetRisk() != "write" {
|
||||
t.Errorf("GetRisk=%q", ts.GetRisk())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_SatisfiesMountable(t *testing.T) {
|
||||
var _ Mountable = newTypedFixture()
|
||||
}
|
||||
|
||||
type adapterArgs struct {
|
||||
Name string `flag:"name"`
|
||||
}
|
||||
|
||||
// TestMountTyped_RegistersFlags verifies the mountTyped adapter wires the
|
||||
// binder-registered flag into cobra. Full Validate/Execute integration is
|
||||
// covered by tests_e2e/shortcuts/ (out of unit-test scope — runShortcut
|
||||
// needs a fully-initialized Factory with auth/config).
|
||||
func TestMountTyped_RegistersFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
sub, _, err := root.Find([]string{"+demo"})
|
||||
if err != nil {
|
||||
t.Fatalf("find subcommand: %v", err)
|
||||
}
|
||||
if sub.Flag("name") == nil {
|
||||
t.Error("expected --name flag to be registered via binder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTyped_HelpFuncInstalled(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Examples: []HelpExample{{Title: "demo", Cmd: "--name alice"}},
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
|
||||
sub, _, _ := root.Find([]string{"+demo"})
|
||||
if sub == nil || sub.HelpFunc() == nil {
|
||||
t.Fatal("expected typed help func installed on subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedShortcut_Mount verifies the no-context convenience Mount API still
|
||||
// works after migration, mirroring legacy Shortcut.Mount so existing tests of
|
||||
// migrated shortcuts don't need to switch to MountWithContext.
|
||||
func TestTypedShortcut_Mount(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
ts := TypedShortcut[*adapterArgs]{
|
||||
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
|
||||
Risk: "read",
|
||||
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
|
||||
}
|
||||
ts.Mount(root, &cmdutil.Factory{})
|
||||
sub, _, err := root.Find([]string{"+demo"})
|
||||
if err != nil || sub == nil {
|
||||
t.Fatalf("find subcommand: %v (sub=%v)", err, sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_GetAuthTypes(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{AuthTypes: []string{"user", "bot"}}
|
||||
if got := ts.GetAuthTypes(); !slices.Equal(got, []string{"user", "bot"}) {
|
||||
t.Errorf("GetAuthTypes=%v", got)
|
||||
}
|
||||
if got := (TypedShortcut[*stubArgs]{}).GetAuthTypes(); got != nil {
|
||||
t.Errorf("empty GetAuthTypes=%v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_ScopesForIdentity(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
Scopes: []string{"base"},
|
||||
UserScopes: []string{"u"},
|
||||
BotScopes: []string{"b"},
|
||||
}
|
||||
cases := []struct {
|
||||
identity string
|
||||
want []string
|
||||
}{
|
||||
{"user", []string{"u"}},
|
||||
{"bot", []string{"b"}},
|
||||
{"other", []string{"base"}},
|
||||
{"", []string{"base"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ts.ScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
|
||||
t.Errorf("ScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
|
||||
}
|
||||
}
|
||||
// Falls back to Scopes when the identity-specific list is empty.
|
||||
fallback := TypedShortcut[*stubArgs]{Scopes: []string{"base"}}
|
||||
if got := fallback.ScopesForIdentity("user"); !slices.Equal(got, []string{"base"}) {
|
||||
t.Errorf("fallback user=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_ConditionalScopesForIdentity(t *testing.T) {
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
ConditionalScopes: []string{"cbase"},
|
||||
ConditionalUserScopes: []string{"cu"},
|
||||
ConditionalBotScopes: []string{"cb"},
|
||||
}
|
||||
cases := []struct {
|
||||
identity string
|
||||
want []string
|
||||
}{
|
||||
{"user", []string{"cu"}},
|
||||
{"bot", []string{"cb"}},
|
||||
{"other", []string{"cbase"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ts.ConditionalScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
|
||||
t.Errorf("ConditionalScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedShortcut_DeclaredScopesForIdentity(t *testing.T) {
|
||||
// Merges base + conditional, dedupes overlap, drops empty strings.
|
||||
ts := TypedShortcut[*stubArgs]{
|
||||
UserScopes: []string{"a", "b", ""},
|
||||
ConditionalUserScopes: []string{"b", "c"},
|
||||
}
|
||||
if got := ts.DeclaredScopesForIdentity("user"); !slices.Equal(got, []string{"a", "b", "c"}) {
|
||||
t.Errorf("merge+dedupe got %v", got)
|
||||
}
|
||||
// Returns nil when nothing is declared on either side.
|
||||
if got := (TypedShortcut[*stubArgs]{}).DeclaredScopesForIdentity("user"); got != nil {
|
||||
t.Errorf("empty got %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -125,23 +125,3 @@ func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetService returns the parent cobra command name (e.g. "im"). Trivial
|
||||
// accessor so *Shortcut satisfies ShortcutDescriptor alongside the existing
|
||||
// pointer-receiver scope methods.
|
||||
func (s *Shortcut) GetService() string { return s.Service }
|
||||
|
||||
// GetCommand returns the shortcut subcommand name (e.g. "+messages-send").
|
||||
func (s *Shortcut) GetCommand() string { return s.Command }
|
||||
|
||||
// GetDescription returns the short help text.
|
||||
func (s *Shortcut) GetDescription() string { return s.Description }
|
||||
|
||||
// GetAuthTypes returns the supported identities. Defaults to ["user"] is
|
||||
// applied at mount time, not here, to preserve the existing field semantics.
|
||||
func (s *Shortcut) GetAuthTypes() []string { return s.AuthTypes }
|
||||
|
||||
// GetRisk returns the static risk level ("read" / "write" / "high-risk-write").
|
||||
// Empty string defaults to "read" by convention; callers must handle that
|
||||
// case (same convention as the existing cobra annotation logic).
|
||||
func (s *Shortcut) GetRisk() string { return s.Risk }
|
||||
|
||||
@@ -105,32 +105,3 @@ func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
|
||||
t.Errorf("expected conditional-only declared scopes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcut_DescriptorAccessors(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "im",
|
||||
Command: "+messages-send",
|
||||
Description: "Send a message",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Risk: "write",
|
||||
}
|
||||
if s.GetService() != "im" {
|
||||
t.Errorf("GetService = %q", s.GetService())
|
||||
}
|
||||
if s.GetCommand() != "+messages-send" {
|
||||
t.Errorf("GetCommand = %q", s.GetCommand())
|
||||
}
|
||||
if s.GetDescription() != "Send a message" {
|
||||
t.Errorf("GetDescription = %q", s.GetDescription())
|
||||
}
|
||||
if got := s.GetAuthTypes(); len(got) != 2 || got[0] != "user" || got[1] != "bot" {
|
||||
t.Errorf("GetAuthTypes = %v", got)
|
||||
}
|
||||
if s.GetRisk() != "write" {
|
||||
t.Errorf("GetRisk = %q", s.GetRisk())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcut_SatisfiesShortcutDescriptor(t *testing.T) {
|
||||
var _ ShortcutDescriptor = &Shortcut{}
|
||||
}
|
||||
|
||||
@@ -53,55 +53,35 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
|
||||
return slices.Contains(allowed, brand)
|
||||
}
|
||||
|
||||
// allShortcuts aggregates shortcuts from all domain packages. Each entry is a
|
||||
// Mountable: both legacy common.Shortcut (via *Shortcut after the descriptor
|
||||
// accessor methods landed) and the new generic common.TypedShortcut[T] satisfy
|
||||
// it. The slice element type is ShortcutDescriptor so read-only consumers
|
||||
// (auth login, scope hint, shortcuts.json generator) can read metadata without
|
||||
// caring about which concrete implementation backs each entry.
|
||||
//
|
||||
// Only legacy shortcuts are registered today; the typed track stays wired
|
||||
// (ShortcutDescriptor / Mountable dispatch + buildTypedHelp) but currently has
|
||||
// no domain caller.
|
||||
var allShortcuts []common.ShortcutDescriptor
|
||||
|
||||
// addLegacy boxes a legacy []common.Shortcut into the descriptor slice. We use
|
||||
// pointer-valued elements because the pointer-receiver scope methods
|
||||
// (ScopesForIdentity / ConditionalScopesForIdentity / DeclaredScopesForIdentity)
|
||||
// are required by ShortcutDescriptor — value receivers don't satisfy them.
|
||||
func addLegacy(list []common.Shortcut) {
|
||||
for i := range list {
|
||||
allShortcuts = append(allShortcuts, &list[i])
|
||||
}
|
||||
}
|
||||
// allShortcuts aggregates shortcuts from all domain packages.
|
||||
var allShortcuts []common.Shortcut
|
||||
|
||||
func init() {
|
||||
addLegacy(apps.Shortcuts())
|
||||
addLegacy(calendar.Shortcuts())
|
||||
addLegacy(doc.Shortcuts())
|
||||
addLegacy(drive.Shortcuts())
|
||||
addLegacy(im.Shortcuts())
|
||||
addLegacy(contact_shortcuts.Shortcuts())
|
||||
addLegacy(sheets.Shortcuts())
|
||||
addLegacy(base.Shortcuts())
|
||||
addLegacy(event.Shortcuts())
|
||||
addLegacy(mail.Shortcuts())
|
||||
addLegacy(markdown.Shortcuts())
|
||||
addLegacy(slides.Shortcuts())
|
||||
addLegacy(minutes.Shortcuts())
|
||||
addLegacy(task.Shortcuts())
|
||||
addLegacy(vc.Shortcuts())
|
||||
addLegacy(whiteboard.Shortcuts())
|
||||
addLegacy(wiki.Shortcuts())
|
||||
addLegacy(okr.Shortcuts())
|
||||
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, im.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, task.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcut descriptors (for
|
||||
// dump-shortcuts and auth/scope-hint consumers).
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
//
|
||||
//go:noinline
|
||||
func AllShortcuts() []common.ShortcutDescriptor {
|
||||
return append([]common.ShortcutDescriptor(nil), allShortcuts...)
|
||||
func AllShortcuts() []common.Shortcut {
|
||||
return append([]common.Shortcut(nil), allShortcuts...)
|
||||
}
|
||||
|
||||
// RegisterShortcuts registers all +shortcut commands on the program.
|
||||
@@ -118,15 +98,10 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
}
|
||||
}
|
||||
|
||||
// Group by service. Each entry is a Mountable so MountWithContext works
|
||||
// uniformly across legacy *Shortcut and TypedShortcut[T].
|
||||
byService := make(map[string][]common.Mountable)
|
||||
for _, d := range allShortcuts {
|
||||
m, ok := d.(common.Mountable)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("shortcut %s/%s missing Mountable", d.GetService(), d.GetCommand()))
|
||||
}
|
||||
byService[d.GetService()] = append(byService[d.GetService()], m)
|
||||
// Group by service
|
||||
byService := make(map[string][]common.Shortcut)
|
||||
for _, s := range allShortcuts {
|
||||
byService[s.Service] = append(byService[s.Service], s)
|
||||
}
|
||||
|
||||
for service, shortcuts := range byService {
|
||||
@@ -165,8 +140,8 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
doc.ConfigureServiceHelp(svc)
|
||||
}
|
||||
|
||||
for _, m := range shortcuts {
|
||||
m.MountWithContext(ctx, svc, f)
|
||||
for _, shortcut := range shortcuts {
|
||||
shortcut.MountWithContext(ctx, svc, f)
|
||||
}
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -48,16 +47,10 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
|
||||
}
|
||||
|
||||
func TestAllShortcutsScopesNotNil(t *testing.T) {
|
||||
for _, d := range allShortcuts {
|
||||
legacy, ok := d.(*common.Shortcut)
|
||||
if !ok {
|
||||
// Typed shortcuts enforce scope-presence at their own test layer
|
||||
// (TypedShortcut[T] integration tests); the descriptor interface
|
||||
// doesn't expose the raw fields needed for the nil-vs-empty check.
|
||||
continue
|
||||
}
|
||||
if legacy.Scopes == nil && legacy.UserScopes == nil && legacy.BotScopes == nil {
|
||||
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", legacy.Service, legacy.Command)
|
||||
for _, s := range allShortcuts {
|
||||
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
|
||||
if !hasScopes {
|
||||
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,8 +62,8 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
}
|
||||
|
||||
hasBaseGet := false
|
||||
for _, d := range shortcuts {
|
||||
if d.GetService() == "base" && d.GetCommand() == "+base-get" {
|
||||
for _, shortcut := range shortcuts {
|
||||
if shortcut.Service == "base" && shortcut.Command == "+base-get" {
|
||||
hasBaseGet = true
|
||||
break
|
||||
}
|
||||
@@ -79,29 +72,9 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
|
||||
t.Fatal("AllShortcuts does not include base/+base-get")
|
||||
}
|
||||
|
||||
// Returned slice is a defensive copy of the descriptor references;
|
||||
// appending to it must not affect the canonical registry. (Element
|
||||
// identity is shared by design — mutation through pointers is the
|
||||
// caller's responsibility to avoid, same as any interface slice.)
|
||||
got := AllShortcuts()
|
||||
got = append(got, &common.Shortcut{Service: "synthetic"})
|
||||
if len(AllShortcuts()) >= len(got) {
|
||||
t.Fatal("AllShortcuts should return a copy that callers can append to without affecting the registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcuts_ReturnsShortcutDescriptors(t *testing.T) {
|
||||
list := AllShortcuts()
|
||||
if len(list) == 0 {
|
||||
t.Fatal("AllShortcuts returned empty slice")
|
||||
}
|
||||
for _, d := range list {
|
||||
if d.GetService() == "" {
|
||||
t.Errorf("shortcut missing service: %T", d)
|
||||
}
|
||||
if d.GetCommand() == "" {
|
||||
t.Errorf("shortcut %q missing command", d.GetService())
|
||||
}
|
||||
shortcuts[0].Service = "mutated"
|
||||
if AllShortcuts()[0].Service == "mutated" {
|
||||
t.Fatal("AllShortcuts should return a copy")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,10 +451,10 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
}
|
||||
grouped := make(map[string][]entry)
|
||||
for _, s := range shortcuts {
|
||||
verb := strings.TrimPrefix(s.GetCommand(), "+")
|
||||
grouped[s.GetService()] = append(grouped[s.GetService()], entry{
|
||||
verb := strings.TrimPrefix(s.Command, "+")
|
||||
grouped[s.Service] = append(grouped[s.Service], entry{
|
||||
Verb: verb,
|
||||
Description: s.GetDescription(),
|
||||
Description: s.Description,
|
||||
Scopes: s.DeclaredScopesForIdentity("user"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVG,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
}
|
||||
|
||||
@@ -121,35 +121,19 @@ var SlidesCreate = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
content := buildPresentationXML(title)
|
||||
slidesStr := runtime.Str("slides")
|
||||
|
||||
// Step 1: Create presentation
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
)
|
||||
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"title": title,
|
||||
}
|
||||
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
|
||||
result["revision_id"] = int(revisionID)
|
||||
if revisionID > 0 {
|
||||
result["revision_id"] = revisionID
|
||||
}
|
||||
|
||||
// Step 2: Add slides if provided
|
||||
@@ -198,6 +182,9 @@ var SlidesCreate = common.Shortcut{
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
}
|
||||
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
|
||||
result["revision_id"] = int(latest)
|
||||
}
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
@@ -205,34 +192,7 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch presentation URL via drive meta (best-effort)
|
||||
if metaData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": presentationID,
|
||||
"doc_type": "slides",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
},
|
||||
); err == nil {
|
||||
metas := common.GetSlice(metaData, "metas")
|
||||
if len(metas) > 0 {
|
||||
if meta, ok := metas[0].(map[string]interface{}); ok {
|
||||
if url := common.GetString(meta, "url"); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
fillPresentationResult(runtime, presentationID, result)
|
||||
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
@@ -259,6 +219,41 @@ func buildPresentationXML(title string) string {
|
||||
)
|
||||
}
|
||||
|
||||
func createEmptyPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/slides_ai/v1/xml_presentations",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": buildPresentationXML(title),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
presentationID := common.GetString(data, "xml_presentation_id")
|
||||
if presentationID == "" {
|
||||
return "", 0, output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
|
||||
}
|
||||
revisionID := 0
|
||||
if rev := common.GetFloat(data, "revision_id"); rev > 0 {
|
||||
revisionID = int(rev)
|
||||
}
|
||||
return presentationID, revisionID, nil
|
||||
}
|
||||
|
||||
func fillPresentationResult(runtime *common.RuntimeContext, presentationID string, result map[string]interface{}) {
|
||||
if url, err := common.FetchDriveMetaURL(runtime, presentationID, "slides"); err == nil && url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
// uploadSlidesPlaceholders uploads each unique placeholder path against the
|
||||
// presentation and returns the path→file_token map. The second return value is
|
||||
// the number of files successfully uploaded before any error, so callers can
|
||||
|
||||
189
shortcuts/slides/slides_create_svg.go
Normal file
189
shortcuts/slides/slides_create_svg.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVG creates a new Lark Slides presentation from one or more
|
||||
// SVGlide SVG files by adding each page through the existing XML slide route.
|
||||
var SlidesCreateSVG = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svg",
|
||||
Description: "Create a Lark Slides presentation from SVG",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{
|
||||
"slides:presentation:create",
|
||||
"slides:presentation:write_only",
|
||||
"docs:document.media:upload",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "presentation title"},
|
||||
{
|
||||
Name: "file",
|
||||
Type: "string_array",
|
||||
Required: true,
|
||||
Desc: "SVG file path; repeat for multiple pages",
|
||||
},
|
||||
{Name: "assets", Desc: "optional assets.json path mapping SVG @path placeholders to uploaded file tokens"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSVGAssetsPath(runtime, runtime.Str("assets"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
filePaths := runtime.StrArray("file")
|
||||
svgs, err := readSVGFiles(runtime, filePaths)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
classified, err := classifySVGlideSVGPages(filePaths, svgs)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
rewriteResult, uploadPaths, err := dryRunRewriteClassifiedSVGPages(classified, assets)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
pages := rewriteResult.Pages
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
total := 1 + len(uploadPaths) + len(pages)
|
||||
descSuffix := ""
|
||||
if len(uploadPaths) > 0 {
|
||||
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(uploadPaths))
|
||||
}
|
||||
dry.Desc(fmt.Sprintf("Create presentation from %d SVG page(s)%s", len(pages), descSuffix)).
|
||||
POST("/open-apis/slides_ai/v1/xml_presentations").
|
||||
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
|
||||
Body(map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
|
||||
})
|
||||
|
||||
for i, path := range uploadPaths {
|
||||
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
|
||||
}
|
||||
|
||||
slideStepStart := 2 + len(uploadPaths)
|
||||
for i, page := range pages {
|
||||
content, injectErr := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
|
||||
if injectErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", injectErr.Error())
|
||||
}
|
||||
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
|
||||
Desc(fmt.Sprintf("[%d/%d] Add SVG page %d", slideStepStart+i, total, i+1)).
|
||||
Params(map[string]interface{}{"revision_id": -1}).
|
||||
Body(buildCreateSVGBody(content))
|
||||
}
|
||||
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
|
||||
}
|
||||
return dry.Set("title", title)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
title := effectiveTitle(runtime.Str("title"))
|
||||
filePaths := runtime.StrArray("file")
|
||||
svgs, err := readSVGFiles(runtime, filePaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
classified, err := classifySVGlideSVGPages(filePaths, svgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasFallbackPages(classified) {
|
||||
if err := svgFallbackRasterizer.CheckAvailable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
renderedFallbacks, err := renderSVGFallbackPages(ctx, classified, svgFallbackRasterizer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupRenderedSVGFallbacks(renderedFallbacks)
|
||||
|
||||
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"title": title,
|
||||
}
|
||||
if revisionID > 0 {
|
||||
result["revision_id"] = revisionID
|
||||
}
|
||||
|
||||
rewriteResult, err := rewriteClassifiedSVGPages(runtime, presentationID, classified, assets, renderedFallbacks)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
|
||||
err, presentationID, rewriteResult.ImagesUploaded)
|
||||
}
|
||||
if rewriteResult.ImagesUploaded > 0 {
|
||||
result["images_uploaded"] = rewriteResult.ImagesUploaded
|
||||
}
|
||||
if rewriteResult.FallbackPages > 0 {
|
||||
result["fallback_pages"] = rewriteResult.FallbackPages
|
||||
}
|
||||
pages := rewriteResult.Pages
|
||||
|
||||
slideURL := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
var slideIDs []string
|
||||
for i, page := range pages {
|
||||
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitValidation, "validation",
|
||||
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
|
||||
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
|
||||
}
|
||||
slideData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": -1},
|
||||
buildCreateSVGBody(content),
|
||||
)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error",
|
||||
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
|
||||
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
|
||||
}
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
}
|
||||
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
|
||||
result["revision_id"] = int(latest)
|
||||
}
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
result["slides_added"] = len(slideIDs)
|
||||
fillPresentationResult(runtime, presentationID, result)
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
649
shortcuts/slides/slides_create_svg_test.go
Normal file
649
shortcuts/slides/slides_create_svg_test.go
Normal file
@@ -0,0 +1,649 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const testSVGlidePage1 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="320" height="180"/></svg>`
|
||||
const testSVGlidePage2 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><p xmlns="http://www.w3.org/1999/xhtml">second</p></foreignObject></svg>`
|
||||
|
||||
func TestSlidesCreateSVGMissingFileFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--title", "missing file",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing --file error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file") {
|
||||
t.Fatalf("err = %v, want mention of file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGFileMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "missing.svg",
|
||||
"--title", "missing svg",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for missing SVG")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing.svg") {
|
||||
t.Fatalf("err = %v, want mention of missing.svg", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGEmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("empty.svg", nil, 0o644); err != nil {
|
||||
t.Fatalf("write empty.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "empty.svg",
|
||||
"--title", "empty svg",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty SVG")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty.svg") || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("err = %v, want empty.svg empty-file message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
|
||||
t.Fatalf("write page1.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
|
||||
t.Fatalf("write page2.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_svg",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
slideStub1 := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
|
||||
}
|
||||
slideStub2 := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}},
|
||||
}
|
||||
reg.Register(slideStub1)
|
||||
reg.Register(slideStub2)
|
||||
registerBatchQueryStub(reg, "pres_svg", "https://x.feishu.cn/slides/pres_svg")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", "SVG Deck",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_svg" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_svg", data["xml_presentation_id"])
|
||||
}
|
||||
if data["slides_added"] != float64(2) {
|
||||
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
|
||||
}
|
||||
if data["revision_id"] != float64(3) {
|
||||
t.Fatalf("revision_id = %v, want latest revision 3", data["revision_id"])
|
||||
}
|
||||
slideIDs, ok := data["slide_ids"].([]interface{})
|
||||
if !ok || len(slideIDs) != 2 || slideIDs[0] != "slide_1" || slideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
|
||||
}
|
||||
|
||||
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
|
||||
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
|
||||
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
|
||||
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
|
||||
t.Fatalf("write page1.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
|
||||
t.Fatalf("write page2.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_svg_partial",
|
||||
"revision_id": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_ok", "revision_id": 2}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": "invalid svg",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", "partial svg",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected slide create failure")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
for _, want := range []string{"pres_svg_partial", "page 2/2", "1 slide(s) added", "slide_ok"} {
|
||||
if !strings.Contains(errMsg, want) {
|
||||
t.Fatalf("err = %v, want mention of %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGFailureExtractsSVGlideMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_marker", "revision_id": 1}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_marker/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400,
|
||||
"msg": `SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`,
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--title", "marker",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected marker failure")
|
||||
}
|
||||
errMsg := err.Error()
|
||||
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject", "Use supported elements"} {
|
||||
if !strings.Contains(errMsg, want) {
|
||||
t.Fatalf("err = %v, want marker field %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGAssetsReplaceImageAndInjectMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></svg>`
|
||||
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
|
||||
t.Fatalf("write assets.json: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_asset", "revision_id": 1}},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_asset/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_asset", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_asset", "https://x.feishu.cn/slides/pres_asset")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--assets", "assets.json",
|
||||
"--title", "assets",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
if strings.Contains(content, "@./hero.png") || strings.Contains(content, "xlink:href") {
|
||||
t.Fatalf("content should canonicalize asset placeholder: %s", content)
|
||||
}
|
||||
for _, want := range []string{`href="boxcn_asset"`, `<metadata data-svglide-assets="true">`, `<img src="boxcn_asset" />`} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
|
||||
t.Fatalf("--assets token mapping should not upload local images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGNestedImageAssetsReplaceAndInjectMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><g transform="translate(10 20)"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></g></svg>`
|
||||
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
|
||||
t.Fatalf("write assets.json: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_nested_asset", "revision_id": 1}},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_nested_asset/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_nested_asset", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_nested_asset", "https://x.feishu.cn/slides/pres_nested_asset")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--assets", "assets.json",
|
||||
"--title", "nested assets",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{
|
||||
`href="boxcn_asset"`,
|
||||
`<metadata data-svglide-assets="true">`,
|
||||
`<img src="boxcn_asset" />`,
|
||||
`<g transform="translate(10 20)">`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{`xlink:href`, `@./hero.png`} {
|
||||
if strings.Contains(content, notWant) {
|
||||
t.Fatalf("content should not contain %s: %s", notWant, content)
|
||||
}
|
||||
}
|
||||
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
|
||||
t.Fatalf("--assets token mapping should not upload local images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" href="@hero.png" x="0" y="0" width="320" height="180"/></svg>`
|
||||
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write page.svg: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("hero.png", []byte("png"), 0o644); err != nil {
|
||||
t.Fatalf("write hero.png: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_upload", "revision_id": 1}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_uploaded"}},
|
||||
})
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_upload/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_upload", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_upload", "https://x.feishu.cn/slides/pres_upload")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "page.svg",
|
||||
"--title", "upload",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["images_uploaded"] != float64(1) {
|
||||
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{`href="boxcn_uploaded"`, `<img src="boxcn_uploaded" />`} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGFallbackRendersUploadsAndAddsImageOnlySVG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">render me</text></svg>`
|
||||
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write fallback.svg: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeSVGFallbackRasterizer{pngPath: "fallback.png", pngBytes: []byte("png-bytes")}
|
||||
restore := setTestSVGFallbackRasterizer(fake)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_fallback", "revision_id": 1}},
|
||||
})
|
||||
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": "boxcn_fallback"}},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
slideStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_fallback/slide",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_fallback", "revision_id": 2}},
|
||||
}
|
||||
reg.Register(slideStub)
|
||||
registerBatchQueryStub(reg, "pres_fallback", "https://x.feishu.cn/slides/pres_fallback")
|
||||
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "fallback.svg",
|
||||
"--title", "fallback",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "fallback.svg" {
|
||||
t.Fatalf("rasterizer calls = %v, want [fallback.svg]", fake.calls)
|
||||
}
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["fallback_pages"] != float64(1) {
|
||||
t.Fatalf("fallback_pages = %v, want 1", data["fallback_pages"])
|
||||
}
|
||||
if data["images_uploaded"] != float64(1) {
|
||||
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v", err)
|
||||
}
|
||||
content := body["slide"].(map[string]interface{})["content"].(string)
|
||||
for _, want := range []string{
|
||||
`slide:contract-version="svglide-authoring-contract/v1"`,
|
||||
`<image slide:role="image" href="boxcn_fallback" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
|
||||
`<metadata data-svglide-assets="true"><img src="boxcn_fallback" /></metadata>`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("fallback slide content missing %s: %s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "<text") {
|
||||
t.Fatalf("fallback slide content should not contain original text node: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGRejectsUnsafeBeforePresentationCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`
|
||||
if err := os.WriteFile("unsafe.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write unsafe.svg: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "unsafe.svg",
|
||||
"--title", "unsafe",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected preflight reject")
|
||||
}
|
||||
for _, want := range []string{"disallowed_script", "unsafe.svg"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGRendererUnavailableBeforePresentationCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">needs fallback</text></svg>`
|
||||
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write fallback.svg: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeSVGFallbackRasterizer{
|
||||
availableErr: newSVGlideDiagnosticsError("renderer unavailable", []SVGlideDiagnostic{{
|
||||
Code: svgDiagRendererUnavailable,
|
||||
Severity: svgDiagSeverityError,
|
||||
Path: "fallback.svg",
|
||||
Message: "renderer missing",
|
||||
}}),
|
||||
}
|
||||
restore := setTestSVGFallbackRasterizer(fake)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "fallback.svg",
|
||||
"--title", "renderer unavailable",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected renderer unavailable error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), svgDiagRendererUnavailable) {
|
||||
t.Fatalf("err = %v, want renderer_unavailable", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("rasterizer should not render when availability check fails, calls=%v", fake.calls)
|
||||
}
|
||||
if fake.checkCalls != 1 {
|
||||
t.Fatalf("renderer availability checks = %d, want 1", fake.checkCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGRasterFailureBeforePresentationCreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="80" y="120">needs fallback</text></svg>`
|
||||
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
|
||||
t.Fatalf("write fallback.svg: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeSVGFallbackRasterizer{
|
||||
renderErr: newSVGlideDiagnosticsError("render failed", []SVGlideDiagnostic{{
|
||||
Code: svgDiagRendererFailed,
|
||||
Severity: svgDiagSeverityError,
|
||||
Path: "fallback.svg",
|
||||
Message: "render failed",
|
||||
}}),
|
||||
}
|
||||
restore := setTestSVGFallbackRasterizer(fake)
|
||||
defer restore()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
|
||||
"+create-svg",
|
||||
"--file", "fallback.svg",
|
||||
"--title", "render failure",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected raster failure error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), svgDiagRendererFailed) {
|
||||
t.Fatalf("err = %v, want renderer_failed", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("rasterizer calls = %v, want one render attempt", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSVGFallbackRasterizer struct {
|
||||
availableErr error
|
||||
renderErr error
|
||||
pngPath string
|
||||
pngBytes []byte
|
||||
checkCalls int
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeSVGFallbackRasterizer) CheckAvailable(context.Context) error {
|
||||
f.checkCalls++
|
||||
return f.availableErr
|
||||
}
|
||||
|
||||
func (f *fakeSVGFallbackRasterizer) Rasterize(_ context.Context, svgPath string) (string, int64, error) {
|
||||
f.calls = append(f.calls, svgPath)
|
||||
if f.renderErr != nil {
|
||||
return "", 0, f.renderErr
|
||||
}
|
||||
if f.pngPath == "" {
|
||||
f.pngPath = "fallback.png"
|
||||
}
|
||||
if len(f.pngBytes) == 0 {
|
||||
f.pngBytes = []byte("png")
|
||||
}
|
||||
if err := os.WriteFile(f.pngPath, f.pngBytes, 0o644); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return f.pngPath, int64(len(f.pngBytes)), nil
|
||||
}
|
||||
|
||||
func setTestSVGFallbackRasterizer(r svgRasterizer) func() {
|
||||
old := svgFallbackRasterizer
|
||||
svgFallbackRasterizer = r
|
||||
return func() {
|
||||
svgFallbackRasterizer = old
|
||||
}
|
||||
}
|
||||
|
||||
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "slides"}
|
||||
SlidesCreateSVG.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func assertSlideCreateBodyContains(t *testing.T, stub *httpmock.Stub, want string) {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode slide body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
slide, _ := body["slide"].(map[string]interface{})
|
||||
content, _ := slide["content"].(string)
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("slide content = %s\nwant to contain %s", content, want)
|
||||
}
|
||||
}
|
||||
1473
shortcuts/slides/svg_helpers.go
Normal file
1473
shortcuts/slides/svg_helpers.go
Normal file
File diff suppressed because it is too large
Load Diff
513
shortcuts/slides/svg_helpers_test.go
Normal file
513
shortcuts/slides/svg_helpers_test.go
Normal file
@@ -0,0 +1,513 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svgs := []string{
|
||||
`<svg><image slide:role="image" href="@./hero.png"/><a href="@./link.png"/></svg>`,
|
||||
`<svg><image xlink:href='@./hero.png'/><image href = "@./other.png"/></svg>`,
|
||||
}
|
||||
got := extractSVGImagePlaceholderPaths(svgs, map[string]string{"@./other.png": "boxcn_other"})
|
||||
want := []string{"./hero.png"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSVGImagePlaceholdersWithTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := `<svg><image slide:role="image" href="@./hero.png"/><image xlink:href='@./logo.png'/><image data-href="@./ignored.png"/><a href="@./link.png">link</a><image href="https://example.com/noop.png"/></svg>`
|
||||
got, tokens := rewriteSVGImagePlaceholdersWithTokens(in, map[string]string{
|
||||
"./hero.png": "boxcn_hero",
|
||||
"./logo.png": "boxcn_logo",
|
||||
})
|
||||
for _, want := range []string{`href="boxcn_hero"`, `href="boxcn_logo"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("rewritten SVG missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "xlink:href") {
|
||||
t.Fatalf("rewritten SVG must not retain xlink:href: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `<a href="@./link.png">`) {
|
||||
t.Fatalf("non-image href should be untouched: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `data-href="@./ignored.png"`) {
|
||||
t.Fatalf("non-href image attribute should be untouched: %s", got)
|
||||
}
|
||||
wantTokens := []string{"boxcn_hero", "boxcn_logo"}
|
||||
if !reflect.DeepEqual(tokens, wantTokens) {
|
||||
t.Fatalf("tokens = %v, want %v", tokens, wantTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSVGTransportAssetMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect/></svg>`
|
||||
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b", "boxcn_a"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
rootIdx := strings.Index(got, "<svg")
|
||||
metaIdx := strings.Index(got, `<metadata data-svglide-assets="true">`)
|
||||
if rootIdx < 0 || metaIdx < rootIdx {
|
||||
t.Fatalf("metadata should be injected inside root <svg>, got: %s", got)
|
||||
}
|
||||
if strings.Count(got, `src="boxcn_a"`) != 1 {
|
||||
t.Fatalf("boxcn_a should be deduped, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `src="boxcn_b"`) {
|
||||
t.Fatalf("boxcn_b missing, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-assets="true"><img src="boxcn_a" /></metadata><image href="boxcn_a"/></svg>`
|
||||
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Count(got, `<metadata data-svglide-assets="true">`) != 1 {
|
||||
t.Fatalf("should keep a single transport metadata block, got: %s", got)
|
||||
}
|
||||
if strings.Count(got, `src="boxcn_a"`) != 1 {
|
||||
t.Fatalf("boxcn_a should remain deduped, got: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `src="boxcn_b"`) {
|
||||
t.Fatalf("boxcn_b should be appended, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySVGlideSVGPageRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
wantMode svgClassifyMode
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "native supported shape",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantMode: svgClassifyNative,
|
||||
},
|
||||
{
|
||||
name: "native supported server line role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><line slide:role="line" x1="0" y1="0" x2="100" y2="60" stroke="#112233"/></svg>`,
|
||||
wantMode: svgClassifyNative,
|
||||
},
|
||||
{
|
||||
name: "native supported server text role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
|
||||
wantMode: svgClassifyNative,
|
||||
},
|
||||
{
|
||||
name: "marked svg text still falls back",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text slide:role="text" x="20" y="40">render me</text></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "wrong contract native rejects",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagContractVersion,
|
||||
},
|
||||
{
|
||||
name: "wrong contract server text role rejects",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagContractVersion,
|
||||
},
|
||||
{
|
||||
name: "unsupported but renderable text falls back",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "wrong contract fallback-only svg still falls back",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "table defaults to fallback",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject x="20" y="40" width="400" height="240"><table xmlns="http://www.w3.org/1999/xhtml"><tr><td>a</td></tr></table></foreignObject></svg>`,
|
||||
wantMode: svgClassifyFallback,
|
||||
wantCode: svgDiagNativeUnsupported,
|
||||
},
|
||||
{
|
||||
name: "script rejects before create",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagDisallowedScript,
|
||||
},
|
||||
{
|
||||
name: "external href rejects before create",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image href="https://example.com/a.png" x="0" y="0" width="10" height="10"/></svg>`,
|
||||
wantMode: svgClassifyReject,
|
||||
wantCode: svgDiagExternalReference,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := classifySVGlideSVGPage(tt.svg, "page.svg", 0)
|
||||
if got.Mode != tt.wantMode {
|
||||
t.Fatalf("mode = %s, want %s; diagnostics=%v", got.Mode, tt.wantMode, got.Diagnostics)
|
||||
}
|
||||
if tt.wantCode == "" {
|
||||
return
|
||||
}
|
||||
if len(got.Diagnostics) == 0 || got.Diagnostics[0].Code != tt.wantCode {
|
||||
t.Fatalf("diagnostics = %v, want first code %s", got.Diagnostics, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSVGFallbackImageOnlyPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">fallback</text></svg>`
|
||||
got, err := buildSVGFallbackImageOnlyPage(source, "boxcn_full_page")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`xmlns:slide="https://slides.bytedance.com/ns"`,
|
||||
`slide:role="slide"`,
|
||||
`slide:contract-version="svglide-authoring-contract/v1"`,
|
||||
`viewBox="0 0 1280 720"`,
|
||||
`<image slide:role="image" href="boxcn_full_page" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("image-only SVG missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if err := validateSVGlideSVG(got, "fallback.svg"); err != nil {
|
||||
t.Fatalf("image-only SVG should be native-valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSVGlideContractRootAttrsInjectsMissingVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
|
||||
got, err := ensureSVGlideContractRootAttrs(source)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) {
|
||||
t.Fatalf("contract version missing: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1" slide:contract-version`) {
|
||||
t.Fatalf("contract version duplicated: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSVGRasterizerUnavailableDiagnostic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := commandSVGRasterizer{
|
||||
command: "missing-svglide-renderer",
|
||||
lookPath: func(string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
},
|
||||
}
|
||||
err := r.CheckAvailable(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected renderer unavailable error")
|
||||
}
|
||||
diags := svglideDiagnosticsFromError(err)
|
||||
if len(diags) != 1 || diags[0].Code != svgDiagRendererUnavailable {
|
||||
t.Fatalf("diagnostics = %v, want renderer_unavailable", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSVGRasterizerArgvAndOutputSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "fake-resvg")
|
||||
argvFile := filepath.Join(dir, "argv.txt")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGV_FILE\"\nprintf png > \"$2\"\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake renderer: %v", err)
|
||||
}
|
||||
in := filepath.Join(dir, "page.svg")
|
||||
if err := os.WriteFile(in, []byte(`<svg/>`), 0o644); err != nil {
|
||||
t.Fatalf("write svg: %v", err)
|
||||
}
|
||||
r := commandSVGRasterizer{
|
||||
command: script,
|
||||
timeout: time.Second,
|
||||
maxOutputSize: 20,
|
||||
env: []string{"ARGV_FILE=" + argvFile},
|
||||
}
|
||||
|
||||
out, size, err := r.Rasterize(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected rasterize error: %v", err)
|
||||
}
|
||||
if size != int64(len("png")) {
|
||||
t.Fatalf("size = %d, want %d", size, len("png"))
|
||||
}
|
||||
if _, err := os.Stat(out); err != nil {
|
||||
t.Fatalf("output file missing: %v", err)
|
||||
}
|
||||
argv, err := os.ReadFile(argvFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read argv: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
|
||||
if len(lines) != 2 || lines[0] != in || lines[1] != out {
|
||||
t.Fatalf("argv = %q, want input and output path", string(argv))
|
||||
}
|
||||
|
||||
r.maxOutputSize = 2
|
||||
_, _, err = r.Rasterize(context.Background(), in)
|
||||
if err == nil {
|
||||
t.Fatal("expected output-size validation error")
|
||||
}
|
||||
diags := svglideDiagnosticsFromError(err)
|
||||
if len(diags) == 0 || diags[0].Code != svgDiagRasterOutputTooLarge {
|
||||
t.Fatalf("diagnostics = %v, want raster_output_too_large", diags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "supported shape rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported text foreignObject",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported server text foreignObject",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported server line role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><line slide:role="line" x1="0" y1="0" x2="100" y2="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported image href",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported image xlink href before rewrite",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" xlink:href="@./hero.png" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "supported path commands",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M1e-3 0 L80 0 H120 V40 C120 60 100 80 80 80 Q40 80 20 40 Z" fill="#123456"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "defs and metadata are ignored",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><defs><rect id="r"/></defs><metadata data-svglide-assets="true"><img src="boxcn_img"/></metadata><circle slide:role="shape" cx="50" cy="50" r="20"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "group container with role-fixed child",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g fill="#112233" transform="translate(10 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nested svg container with role-fixed child",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
|
||||
},
|
||||
{
|
||||
name: "group container ignores its own role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="shape"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nested svg container ignores its own role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg slide:role="shape" viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
|
||||
},
|
||||
{
|
||||
name: "style and nested defs are ignored",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.primary{fill:#123456}</style><g><defs><linearGradient id="g"><stop offset="0%" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient></defs></g><rect slide:role="shape" class="primary" x="0" y="0" width="100" height="60" fill="url(#g)"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "filter and shadow styles are preserved",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.card{filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2));box-shadow:0 8px 20px rgba(0,0,0,.18)}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><rect slide:role="shape" class="card" x="0" y="0" width="100" height="60" filter="url(#shadow)"/></svg>`,
|
||||
},
|
||||
{
|
||||
name: "foreignObject XHTML subtree is not role-validated",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml"><span>hello</span></div></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "foreignObject XHTML br is allowed",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml">hello<br />world</div></foreignObject></svg>`,
|
||||
},
|
||||
{
|
||||
name: "namespaced root is rejected with precise message",
|
||||
svg: `<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg:svg>`,
|
||||
wantErr: `root element must be non-namespaced <svg>`,
|
||||
},
|
||||
{
|
||||
name: "root child missing role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
|
||||
},
|
||||
{
|
||||
name: "group child missing role is rejected",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
wantErr: `<rect> must include slide:role="shape", "image", "line", or "text"`,
|
||||
},
|
||||
{
|
||||
name: "unsupported text element remains rejected",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="shape" x="0" y="20">bad</text></svg>`,
|
||||
wantErr: `<text slide:role="shape"> is not supported by SVGlide`,
|
||||
},
|
||||
{
|
||||
name: "rect shape requires geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" height="60"/></svg>`,
|
||||
wantErr: `<rect slide:role="shape"> missing required attribute "width"`,
|
||||
},
|
||||
{
|
||||
name: "path shape requires d",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" fill="#123456"/></svg>`,
|
||||
wantErr: `<path slide:role="shape"> missing required attribute "d"`,
|
||||
},
|
||||
{
|
||||
name: "rect rejects percent geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="50%" height="60"/></svg>`,
|
||||
wantErr: `attribute "width" must be a number or px length`,
|
||||
},
|
||||
{
|
||||
name: "rect rejects calc geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="calc(10px)" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `attribute "x" must be a number or px length`,
|
||||
},
|
||||
{
|
||||
name: "container transform rejects percent argument",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g transform="translate(10% 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
|
||||
wantErr: `transform translate() argument must be a number or px length`,
|
||||
},
|
||||
{
|
||||
name: "path rejects arc command",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20" fill="#123456"/></svg>`,
|
||||
wantErr: `unsupported path command or character "A"`,
|
||||
},
|
||||
{
|
||||
name: "path rejects smooth command",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 S10 10 20 20" fill="#123456"/></svg>`,
|
||||
wantErr: `unsupported path command or character "S"`,
|
||||
},
|
||||
{
|
||||
name: "plain metadata remains rejected",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
|
||||
wantErr: `<metadata> must include slide:role="shape", "image", "line", or "text"`,
|
||||
},
|
||||
{
|
||||
name: "foreignObject shape requires text type",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
|
||||
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
|
||||
},
|
||||
{
|
||||
name: "line role must be line tag",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="line" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<rect slide:role="line"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "text role must be foreignObject tag",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="text" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<rect slide:role="text"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "svg text role is not native yet",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="text" x="0" y="20">later</text></svg>`,
|
||||
wantErr: `<text slide:role="text"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "image role must be image tag",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,
|
||||
wantErr: `<rect slide:role="image"> is not supported`,
|
||||
},
|
||||
{
|
||||
name: "image requires href",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<image slide:role="image"> must include href`,
|
||||
},
|
||||
{
|
||||
name: "image requires geometry",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" height="60"/></svg>`,
|
||||
wantErr: `<image slide:role="image"> missing required attribute "width"`,
|
||||
},
|
||||
{
|
||||
name: "image rejects external href",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="https://images.unsplash.com/photo.jpg" x="0" y="0" width="100" height="60"/></svg>`,
|
||||
wantErr: `<image slide:role="image"> must not use external http(s) or data href`,
|
||||
},
|
||||
{
|
||||
name: "unsupported role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="decor"/></svg>`,
|
||||
wantErr: `unsupported slide:role="decor"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateSVGlideSVG(tt.svg, "page.svg")
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSVGlideErrorJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
|
||||
got := extractSVGlideErrorJSON(err)
|
||||
if got["type"] != "svg_validation_error" {
|
||||
t.Fatalf("type = %v", got["type"])
|
||||
}
|
||||
if got["tag_name"] != "foreignObject" {
|
||||
t.Fatalf("tag_name = %v", got["tag_name"])
|
||||
}
|
||||
suffix := formatSVGlideErrorSuffix(err)
|
||||
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
|
||||
if !strings.Contains(suffix, want) {
|
||||
t.Fatalf("suffix = %q, want %q", suffix, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md`、`svg-protocol.md` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
@@ -24,15 +25,19 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
**CRITICAL — 走 XML 创建/编辑路径时,生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径(`slides +create-svg`)时,MUST 改读 [svg-protocol.md](references/svg-protocol.md),不要求读取 XML schema。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVG:root `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。**
|
||||
|
||||
**CRITICAL — 高质量 SVG deck 生成时,MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan,再定义布局盒,给 `foreignObject` 文本留足安全高度,默认必须使用真实图片资产(本地 `@./path` 或 file token),相邻页面要显著换版式;调用 API 前必须跑本地 preflight(优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)),live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py),SVG 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
@@ -77,7 +82,7 @@ lark-cli auth login --domain slides
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md);SVG 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
@@ -99,7 +104,7 @@ lark-cli auth login --domain slides
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
@@ -123,7 +128,8 @@ lark-cli auth login --domain slides
|
||||
- 不要所有页面复用同一种标题 + 三 bullets 版式。
|
||||
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
|
||||
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要使用版权状态不明的图片、logo、截图或素材;图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产,并在产物说明或素材清单中记录来源、授权/许可类型、原始 URL 和是否需要署名。
|
||||
- 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
@@ -132,6 +138,7 @@ lark-cli auth login --domain slides
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| AI 生成 SVGlide SVG(希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
@@ -160,10 +167,10 @@ Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行;SVG 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML,仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
@@ -259,6 +266,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+create-svg`](references/lark-slides-create-svg.md) | 从一个或多个 SVGlide SVG 文件创建 PPT,按 `--file` 顺序逐页调用现有 `/slide` 路由 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
|
||||
@@ -272,19 +280,20 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加;AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
8. **图片只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:XML 路径使用 `<img src="...">`;SVG 路径使用 `<image slide:role="image" href="...">`。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src` / `href`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
## 权限速查
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload`) |
|
||||
| `slides +create-svg` | `slides:presentation:create`, `slides:presentation:write_only`, `docs:document.media:upload` |
|
||||
| `slides +media-upload` | `docs:document.media:upload`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `slides +replace-slide` | `slides:presentation:update`(wiki URL 解析还需 `wiki:node:read`) |
|
||||
| `xml_presentations.get` | `slides:presentation:read` |
|
||||
@@ -293,4 +302,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
> **注意**:XML 路径如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准;SVG 路径以 [svg-protocol.md](references/svg-protocol.md) 为准。
|
||||
|
||||
## SVG 排障
|
||||
|
||||
`slides +create-svg` 失败时,优先查看错误中是否包含 `svglide_error` 或服务端 `SVGLIDE_ERROR_JSON:` marker。常见修复:
|
||||
|
||||
- `svg_validation_error`:按 [svg-protocol.md](references/svg-protocol.md) 修正 root `<svg>`、`xmlns:slide`、`slide:role` 或不支持元素。
|
||||
- 图片不显示:确认 `<image>` 使用 canonical `href="file_token"`,不要保留 `xlink:href`;本地图片用 `href="@./image.png"` 让 CLI 上传,或用 `--assets assets.json` 提供 token 映射。
|
||||
- 有 file token 仍失败:确认 SVG 内存在 transport metadata:`<metadata data-svglide-assets="true"><img src="同一个 file_token" /></metadata>`;`+create-svg` 会自动注入,手写 SVG 时不要删除。
|
||||
|
||||
450
skills/lark-slides/references/lark-slides-create-svg.md
Normal file
450
skills/lark-slides/references/lark-slides-create-svg.md
Normal file
@@ -0,0 +1,450 @@
|
||||
# slides +create-svg
|
||||
|
||||
从一个或多个 SVGlide SVG 文件创建飞书幻灯片:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svg \
|
||||
--as user \
|
||||
--title "Demo" \
|
||||
--file page1.svg \
|
||||
--file page2.svg
|
||||
```
|
||||
|
||||
## 适用场景
|
||||
|
||||
- AI 已经能生成符合 [svg-protocol.md](svg-protocol.md) 的 SVGlide SVG。
|
||||
- 希望按文件逐页创建,避免把大段 XML/SVG 塞进 shell 参数。
|
||||
- 需要 SVG 内本地图片占位符自动上传并替换为 file token。
|
||||
|
||||
不适用:
|
||||
|
||||
- 你只有普通 SVG,且没有 `slide:role` 协议标记。
|
||||
- 复杂普通 SVG 不能直接提交;需要把实际可渲染元素标成 SVGlide role。`g` / 嵌套 `svg` 容器可以保留,但不能代替子元素 role。
|
||||
- 你需要插入到指定页前;MVP 只创建新 presentation 并按顺序追加页面。
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--title` | presentation 标题,省略时为 `Untitled` |
|
||||
| `--file` | SVG 文件路径;可重复,页面顺序就是 flag 顺序 |
|
||||
| `--assets` | 可选 `assets.json`,把 SVG `@path` 映射到已上传 file token |
|
||||
| `--dry-run` | 展示创建空白 presentation + N 次 `/slide` 调用,不真实创建 |
|
||||
|
||||
## 请求链路
|
||||
|
||||
CLI 先创建空白 presentation:
|
||||
|
||||
```http
|
||||
POST /open-apis/slides_ai/v1/xml_presentations
|
||||
```
|
||||
|
||||
随后对每个 SVG 文件调用现有 slide create 路由:
|
||||
|
||||
```http
|
||||
POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1
|
||||
```
|
||||
|
||||
body:
|
||||
|
||||
```json
|
||||
{
|
||||
"slide": {
|
||||
"content": "<svg ...>...</svg>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不会新增 `/svg_slide` 路由,也不会把 `file_meta_map` 当成 CLI 到服务端的契约。
|
||||
|
||||
## 图片处理
|
||||
|
||||
SVG 内本地图片写成:
|
||||
|
||||
```xml
|
||||
<image slide:role="image" href="@./hero.png" x="0" y="0" width="320" height="180" />
|
||||
```
|
||||
|
||||
`<image>` 可以位于 `g` / 嵌套 `svg` 容器中;CLI 会全局扫描 `<image href="@...">` 或 `<image xlink:href="@...">` 并替换为 canonical `href="file_token"`。
|
||||
|
||||
CLI 会:
|
||||
|
||||
1. 上传本地图片到新 presentation。
|
||||
2. 把 `href="@./hero.png"` 或 `xlink:href="@./hero.png"` 替换为 canonical `href="file_token"`。
|
||||
3. 注入 transport metadata:`<metadata data-svglide-assets="true"><img src="file_token" /></metadata>`。
|
||||
|
||||
预上传资源可用 `--assets`:
|
||||
|
||||
```json
|
||||
{
|
||||
"@./hero.png": "boxcn..."
|
||||
}
|
||||
```
|
||||
|
||||
## 生成质量规则
|
||||
|
||||
这些规则用于生成阶段主动规避服务端降级、近似和泛化错误。几何数值、path 命令、role/必填属性、图片 href 等基础约束已由 CLI 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
|
||||
|
||||
### 与现有规划层对齐
|
||||
|
||||
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
|
||||
|
||||
在通用 plan 字段基础上,SVG deck 还应补充这些 SVG 专属字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"output_mode": "svglide-svg",
|
||||
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
|
||||
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
|
||||
"svg_constraints": {
|
||||
"text_element": "foreignObject slide:role=shape slide:shape-type=text",
|
||||
"path_commands": "M/L/H/V/C/Q/Z only",
|
||||
"image_href": "@./path or file token only",
|
||||
"css": "explicit font-size/font-weight/color/line-height/text-align; no font shorthand"
|
||||
},
|
||||
"svg_files": [
|
||||
{"page": 1, "path": ".lark-slides/plan/<deck-id>/pages/page-001.svg"}
|
||||
],
|
||||
"preflight": {
|
||||
"command": "python3 skills/lark-slides/scripts/svg_preflight.py --input .lark-slides/plan/<deck-id>/pages/page-001.svg",
|
||||
"status": "pending"
|
||||
},
|
||||
"readback_verification": {
|
||||
"status": "pending",
|
||||
"checks": ["page_count", "blank_page", "canvas_bounds", "text_overlap", "asset_tokens", "closing_slide"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes,不要照搬模板 XML,也不要读取完整模板 XML。
|
||||
|
||||
### 生成前强约束
|
||||
|
||||
以下规则来自实际 SVGlide live 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。
|
||||
|
||||
- MUST: 默认使用 Lark Slides 当前回读画布 `960 x 540`,即 root 写成 `width="960" height="540" viewBox="0 0 960 540"`。不要默认用 `1280 x 720`,否则服务端回读后可能整页偏大并裁切。
|
||||
- MUST: 主体元素使用安全区,建议 `safe = x:48 y:40 w:864 h:460`。除全屏背景外,文本、卡片、图表、标签、节点和图例都必须落在安全区内。
|
||||
- MUST: 多页 deck 应包含明确的 closing slide。8 页以上讲解/汇报型 deck 不要把 roadmap / next-playbook 当作结束页;最后一页应包含 `closing`、`summary`、`Q&A`、`Thanks` 或下一步联系信息。
|
||||
- MUST: `foreignObject` 文本样式使用显式 CSS:`font-size`、`font-weight`、`font-family`、`color`、`line-height`、`text-align`。不要用 `font:` shorthand 表达关键字号和加粗。
|
||||
- MUST: 提交前和 live 回读后都检查边界和重叠:非背景元素不得越过 `960 x 540`,第 2/3 页等信息密集页必须额外检查 text bbox overlap。
|
||||
- SHOULD: 如果本地预览使用更大画布,例如 `1280 x 720`,必须在输出给 `slides +create-svg` 前按比例换算为 `960 x 540`,而不是只改 root viewBox。
|
||||
|
||||
### 生成器实现约束与 Preflight
|
||||
|
||||
生成器必须先把高概率错误拦在本地,再调用 `lark-cli`。不要依赖 live 创建后的人工修补来发现基础问题。
|
||||
|
||||
实现约束:
|
||||
|
||||
- MUST: SVG 生成 helper 的返回类型保持一致。推荐统一返回 `string`,或统一返回 `string[]` 后在页面末尾 `flat().filter(Boolean).join("\n")`;不要混用 `...items.map(...).join("\n")`,这会把已拼好的 SVG 标签按字符展开,生成非法 XML。
|
||||
- MUST: 所有组件都从稳定布局盒推导坐标,避免散点手调。文本、标签、图例、曲线端点和卡片内容应有明确的父盒和对齐规则。
|
||||
- MUST: 生成脚本要先写 deck plan / asset list,再写页面;不能边补坐标边生成最终 SVG。
|
||||
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px,曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
|
||||
- SHOULD: 把每页的 `safe`、`titleBox`、`visualBox`、`textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。
|
||||
|
||||
本地 preflight 必须在 `slides +create-svg` 前执行,失败即停:
|
||||
|
||||
- `python3 skills/lark-slides/scripts/svg_preflight.py --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
|
||||
- root 是 `width="960" height="540" viewBox="0 0 960 540"`。
|
||||
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
|
||||
- 禁止 `font:` shorthand、http(s) / data URL 图片、未下载的远程图片、空图片框。
|
||||
- 禁止 unsupported path command;`path d` 只含 `M/L/H/V/C/Q/Z`。
|
||||
- 非背景元素不得越界;主体元素应在 safe area 内。
|
||||
- 文本框做 bbox overlap 近似检查,尤其是目录、痛点、竞品表、案例图表和总结页。
|
||||
- 图片资产文件存在、大小合理、授权来源清单完整。
|
||||
|
||||
创建顺序:
|
||||
|
||||
```text
|
||||
generate deck plan -> generate assets -> generate SVG files
|
||||
-> local preflight -> lark-cli slides +create-svg --dry-run
|
||||
-> live create -> xml_presentations get readback
|
||||
-> readback bbox / text overlap / closing slide checks
|
||||
```
|
||||
|
||||
readback 不能省略。服务端会把 SVGlide 转成 Slides XML,文字 bbox、path bounds 和图片 token 可能和本地 SVG 预估不同;本地 preflight 负责拦住确定错误,readback 负责发现转换后的版式漂移。
|
||||
|
||||
### Deck 级密度规划
|
||||
|
||||
生成多页 SVG deck 前,先写 deck-level plan。页面类型只定义叙事职责,密度由 `deck_type`、受众、页面目的和节奏共同决定,不要把某个 page type 永久绑定为固定密度。
|
||||
|
||||
最小 plan schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"deck_type": "explain | decision | product | brand | technical | education | report",
|
||||
"audience": "who will read it",
|
||||
"goal": "what the deck should make the audience understand or decide",
|
||||
"density_strategy": "how low/medium/high density pages are distributed",
|
||||
"asset_strategy": "which real images are needed, where they will be used, copyright/license source, and fallback if unavailable",
|
||||
"visual_rhythm": "how layout, imagery, charts, and text density vary across pages",
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"page_type": "cover",
|
||||
"density": "low",
|
||||
"density_mode": "visual-dense",
|
||||
"takeaway": "one sentence the audience should remember",
|
||||
"evidence": [],
|
||||
"visual_structure": "full-bleed image with title overlay",
|
||||
"layout_guardrails": ["large hero title", "no dense body copy"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
常用 `page_type`:
|
||||
|
||||
```text
|
||||
cover, opener, agenda, section-divider, context, problem, opportunity,
|
||||
executive-summary, content, data, comparison, process, case-study, demo,
|
||||
architecture, system, roadmap, timeline, decision, recommendation,
|
||||
risk, tradeoff, summary, closing, q-and-a, appendix
|
||||
```
|
||||
|
||||
密度规则:
|
||||
|
||||
- MUST: 每页都要有明确 `takeaway`,即使是封面、分隔页和结束页。
|
||||
- MUST: 每个 SVG deck 默认都要包含真实图片资产,不要全程只用矢量 shape 冒充“配图”。展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。
|
||||
- MUST: 高密度页必须有承载信息的视觉结构,例如矩阵、流程、地图、时间线、标注图、案例卡或手绘微图表,不能只有装饰图形。
|
||||
- SHOULD: 高密度内容页通常包含 3-6 个信息块和若干可读细节,但 executive brief、品牌页、产品视觉页、短汇报可以降低数量,只保留强结论、关键证据和视觉锚点。
|
||||
- SHOULD NOT: 不要让所有高密度页长成同一种“主结论 + 3-6 卡片 + 3 个 callout”模板。
|
||||
- MUST NOT: 缺少素材或数据时不要编造数字、客户名、logo、排名、引用或真实案例;用 qualitative label、relative scale、hypothesis/assumption 标注兜底。
|
||||
|
||||
### 结构示例
|
||||
|
||||
8-10 页讲解型 deck 可参考这个节奏,但不要把它当成唯一模板;如果 deck 已经包含 roadmap / playbook,仍建议再补一页 closing:
|
||||
|
||||
```text
|
||||
cover -> opener/context -> agenda/map -> content -> data/comparison
|
||||
-> process/system breakdown -> case-study/demo -> content/implications
|
||||
-> summary -> closing
|
||||
```
|
||||
|
||||
5 页决策汇报优先前置结论:
|
||||
|
||||
```text
|
||||
cover -> executive-summary -> options/comparison -> recommendation/risk -> next steps
|
||||
```
|
||||
|
||||
6 页产品/品牌 deck 可以强化视觉叙事:
|
||||
|
||||
```text
|
||||
cover -> value proposition -> user scenario -> feature map/demo
|
||||
-> proof/roadmap -> closing
|
||||
```
|
||||
|
||||
边界处理:
|
||||
|
||||
- 3-5 页短 deck 可以省略 agenda,把 summary 并入 closing。
|
||||
- 15 页以上长 deck 应增加 section-divider 或 recap,避免连续高密度阅读疲劳。
|
||||
- 技术方案要混合 architecture、process、tradeoff、risk,不要连续堆文字。
|
||||
- 教学讲解要前置 context / concept map,逐步增加密度。
|
||||
- 素材不足时,用抽象视觉系统、定性矩阵、annotated wireframe、scenario card 兜底,并标明假设。
|
||||
|
||||
### 先定义布局盒
|
||||
|
||||
不要直接手写散点坐标。每页先定义稳定布局盒,再把文字、图形、图例和图片放进盒内:
|
||||
|
||||
```text
|
||||
page = 960 x 540
|
||||
safe = x:48 y:40 w:864 h:460
|
||||
titleBox = x:54 y:52 w:600 h:96
|
||||
visualBox = x:516 y:176 w:350 h:260
|
||||
notesGrid = x:54 y:430 w:760 h:48
|
||||
```
|
||||
|
||||
生成后检查:
|
||||
|
||||
- 关键元素必须在 safe area 内。
|
||||
- 同组元素使用同一个父盒推导坐标。
|
||||
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
|
||||
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `x + 10`、`y + 10` 维持观感。
|
||||
- 不要把 1280x720 的坐标直接提交给 `slides +create-svg`。当前服务端回读画布通常是 960x540,错误坐标系会表现为每页偏大、右侧卡片裁切、底部标签越界。
|
||||
|
||||
### 文本安全余量
|
||||
|
||||
`foreignObject` 文本优先使用显式 CSS。为了服务端转换到 SXSD/XML 后保留样式,字号、加粗、颜色、行距和对齐必须写成独立属性;不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上:
|
||||
|
||||
```xml
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="54" y="62" width="600" height="42">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"
|
||||
style="margin:0;padding:0;font-size:30px;font-weight:900;font-family:Arial,'Source Han Sans SC';color:#111827;line-height:1.12;text-align:left;letter-spacing:0;">
|
||||
关键结论:增长来自三件事
|
||||
</div>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
中文和混排字体要留安全高度:
|
||||
|
||||
- subtitle 不小于 64px。
|
||||
- note / chip 单行文本盒不小于 20px。
|
||||
- 小型标签文本盒不小于 14px。
|
||||
- 多行文字要按行高预估高度,再额外留 8-12px。
|
||||
- 右侧图例或矩阵格里的文字不得贴边,水平 padding 至少 10-14px。
|
||||
- 服务端支持 `foreignObject` 内的 `<br />`。为了本地预览和标题排版稳定,标题/大段文本优先使用多个块级 `div` 或 `p` 控制行高,不要只靠 `<br />` 调整复杂布局。
|
||||
- 如果需要垂直居中,优先通过更准确的文本框高度、段落行高和 y 坐标解决;布局 wrapper 可以使用,但实际文字节点仍要带显式 `font-size` / `font-weight` / `color`。
|
||||
|
||||
### 几何与 path 安全线
|
||||
|
||||
leaf 几何属性必须写数字或 `px`,不要生成百分比、`em/rem`、`calc(...)`:
|
||||
|
||||
```xml
|
||||
<rect slide:role="shape" x="80" y="96" width="420px" height="240px" />
|
||||
```
|
||||
|
||||
`path d` 只生成 `M/L/H/V/C/Q/Z` 命令。不要生成 `A`、`S`、`T` 等命令;需要圆角或弧线时,用 `C` / `Q` 近似,或改用 `circle` / `ellipse` / `rect`。
|
||||
|
||||
Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`,先在布局盒里换算成绝对坐标。
|
||||
|
||||
### 版式节奏
|
||||
|
||||
同一 deck 不能连续复用同一种“暗色网格 + 左文案 + 右卡片 + 底部 chips”。10 页左右的讲解型 deck 至少混用这些结构:
|
||||
|
||||
- 封面 / 全幅图片背景页。
|
||||
- 目录矩阵页或行业地图页。
|
||||
- 左文右图 / 左图右文双栏页。
|
||||
- 全幅路线图或时间线页。
|
||||
- 2x2 / 2x4 总结矩阵页。
|
||||
- 数据仪表页、流程页、对比页或案例页。
|
||||
|
||||
相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。
|
||||
|
||||
### 图片使用
|
||||
|
||||
默认必须规划和使用图片资产,并规避版权风险。图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产;不要使用版权状态不明的图片、logo、截图、新闻配图、竞品官网图或搜索引擎随手抓取的素材。正确流程是先下载或生成到本地,再写成本地占位符:
|
||||
|
||||
```xml
|
||||
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
|
||||
```
|
||||
|
||||
图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图或图鉴封面。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。
|
||||
|
||||
图片数量与用法建议:
|
||||
|
||||
- MUST: 在 `asset_strategy` 或产物 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式;无法确认授权时不得使用。
|
||||
- MUST: 5 页以上 deck 至少使用 1 张真实图片;8 页以上 deck 至少使用 2 张;宣传/产品/品牌/案例型 deck 至少使用 3 张。
|
||||
- MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。
|
||||
- MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。
|
||||
- SHOULD: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景和标注型截图,避免所有图片都只是小卡片背景。
|
||||
- MUST NOT: 保留空图片框、破图、http(s) 外链或 data URL。素材不可用时要重新获取/生成,或在最终说明中明确为什么退回矢量。
|
||||
|
||||
优先使用这些来源,但每张图仍必须检查并记录具体页面上的授权信息:
|
||||
|
||||
| Source | 适合用途 | 规则 |
|
||||
|--------|----------|------|
|
||||
| Unsplash | 高质量摄影、封面背景、场景图 | 可商用图库;记录图片页 URL 和 license |
|
||||
| Pexels | 商务、科技、生活类配图 | 可商用图库;记录图片页 URL 和 license |
|
||||
| Pixabay | 图片、插画、视频、音频 | 可商用图库;避开人物/品牌/商标误导 |
|
||||
| Openverse | CC / Public Domain 搜索 | 每张图 license 不同;按单图要求署名 |
|
||||
| Wikimedia Commons | 百科、历史、技术、公共领域素材 | 每张图 license 不同;常见需要署名 |
|
||||
| The Met Open Access | 艺术品、历史图像、文化视觉 | 仅使用 Open Access / CC0 条目 |
|
||||
| Smithsonian Open Access | 博物馆、科学、历史、2D/3D 资产 | 仅使用 Open Access / CC0 条目 |
|
||||
| NASA Image and Video Library | 太空、科技、地球、航天视觉 | 避开 NASA 标识商业背书、人物肖像和第三方权利 |
|
||||
|
||||
素材清单建议字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"local_path": "./assets/hero.jpg",
|
||||
"source": "Unsplash",
|
||||
"source_url": "https://...",
|
||||
"license": "Unsplash License",
|
||||
"commercial_use": true,
|
||||
"attribution_required": false,
|
||||
"notes": "No recognizable trademark or misleading endorsement"
|
||||
}
|
||||
```
|
||||
|
||||
### 信息密度与图鉴感
|
||||
|
||||
短 note 不要占一个很宽胶囊。优先写成“编号/标签 + 主句 + 微解释/数值”:
|
||||
|
||||
```text
|
||||
03 GRID ENERGY 86% | storage demand peaks before grid balancing
|
||||
```
|
||||
|
||||
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
|
||||
|
||||
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
|
||||
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;不要默认依赖 Slides 原生 chart,也不要把外部图表截图当成唯一方案。
|
||||
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
|
||||
|
||||
视觉区要补足可读细节,避免只有装饰符号:
|
||||
|
||||
- 局部标注、刻度、坐标轴、图例。
|
||||
- 行业标签、材料纹理、指标卡。
|
||||
- 路线节点、连接线、层级分区。
|
||||
- 小型表格、雷达/柱状/散点等微图表。
|
||||
|
||||
### 生成后检查
|
||||
|
||||
生成脚本或人工复核必须检查:
|
||||
|
||||
- 是否已执行本地 preflight,且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
|
||||
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
|
||||
- live 创建后是否已用 `xml_presentations get` 读回,重新检查画布、页数、越界、文本重叠和 closing slide。
|
||||
- root / leaf role 是否完整。
|
||||
- 每个 leaf 是否有 [svg-protocol.md](svg-protocol.md) 中列出的几何必填属性。
|
||||
- 几何属性和 transform 参数是否只使用数字或 `px`。
|
||||
- `path d` 是否只包含 `M/L/H/V/C/Q/Z`。
|
||||
- 文本是否截断、重叠或贴边。
|
||||
- 内容是否在 safe area 内,关键图例和外框是否对齐。
|
||||
- 相邻页面是否明显换版式。
|
||||
- 每页是否有明确 takeaway;高密度页的视觉结构是否承载信息,而不只是装饰。
|
||||
- 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。
|
||||
- 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
|
||||
- 图片是否已变成本地 `@./path` 或 file token,不能保留 http(s) / data URL。
|
||||
|
||||
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 的 `readback_verification` 字段,并在最终回复中简述:
|
||||
|
||||
```text
|
||||
验证记录:
|
||||
- Preflight:N/N SVG 通过 root/role/geometry/path/image/bbox 检查。
|
||||
- Dry-run:已确认 create presentation + N 次 /slide。
|
||||
- Readback:实际页数 N / 预期 N;未发现空白页、破图或缺失 closing slide。
|
||||
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
|
||||
- 资产:图片均为本地 @path 自动上传或 file token,无 http(s)/data URL。
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
任一页失败时,错误会包含:
|
||||
|
||||
- `xml_presentation_id`
|
||||
- 失败页序号
|
||||
- 已成功页数
|
||||
- 已创建的 `slide_ids`
|
||||
|
||||
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` marker,CLI 会提取并在错误中展示 `svglide_error`,用于定位 `type`、`page_index`、`tag_name`、`element_id`、`role` 和 `hint`。
|
||||
|
||||
失败后不要假设没有创建任何资源。先把恢复状态写回 plan 的 `recovery` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "slides...",
|
||||
"failed_page": 3,
|
||||
"failed_svg_file": ".lark-slides/plan/<deck-id>/pages/page-003.svg",
|
||||
"successful_slide_ids": ["abc", "def"],
|
||||
"svglide_error": {"type": "svg_validation_error", "tag_name": "foreignObject"},
|
||||
"next_action": "fix source SVG and rerun preflight before retry"
|
||||
}
|
||||
```
|
||||
|
||||
恢复顺序:
|
||||
|
||||
1. 本地 preflight 已失败:修对应 SVG 文件,不要调用 live API。
|
||||
2. live 添加页失败且带 `svglide_error`:按 `type` / `tag_name` / `hint` 收敛 SVG 子集,例如降级复杂 filter、path、CSS 或文本结构。
|
||||
3. plain XML 在同一路由成功但 SVG 失败:优先确认目标 server lane 是否部署了 SVGlide parser,不要盲目重写整套 deck。
|
||||
4. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。
|
||||
|
||||
### 编辑已创建的 SVG deck
|
||||
|
||||
SVG deck 后续编辑走双轨,不承诺 source SVG id 能稳定映射到 readback XML block id:
|
||||
|
||||
| 修改类型 | 推荐路径 | 说明 |
|
||||
|----------|----------|------|
|
||||
| 小改标题、文本、图片或坐标 | `xml_presentation.slide.get` 读回 XML -> 找当前 block_id -> `slides +replace-slide` | 使用转换后的 XML 做块级编辑,页序和 slide_id 不变 |
|
||||
| 大幅换版式、重画图表、调整视觉系统 | 修改 source SVG -> 重新 preflight -> 重新创建或替换目标页 | 保持 SVG 的视觉表达优势,避免在转换后 XML 上手搓复杂 SVG 结构 |
|
||||
| 无法定位 block_id 或映射不可信 | 回 source SVG 修改 | 不生成 `edit-map.json`,除非服务端或转换结果能证明 source id 可稳定保留 |
|
||||
|
||||
小改前必须重新 `slide.get` 拿最新 block id 和 revision;大改后必须更新同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。
|
||||
114
skills/lark-slides/references/svg-protocol.md
Normal file
114
skills/lark-slides/references/svg-protocol.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# SVGlide SVG Protocol
|
||||
|
||||
最小模板:
|
||||
|
||||
```xml
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide"
|
||||
width="960"
|
||||
height="540"
|
||||
viewBox="0 0 960 540"
|
||||
>
|
||||
<rect slide:role="shape" x="60" y="60" width="240" height="135" fill="#E8EEF8" />
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" x="90" y="98" width="240" height="60">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:20px;font-weight:700;color:#1F2937;line-height:1.2">SVGlide</div>
|
||||
</foreignObject>
|
||||
<image slide:role="image" href="@./hero.png" x="390" y="60" width="240" height="135" />
|
||||
</svg>
|
||||
```
|
||||
|
||||
## 必须满足
|
||||
|
||||
- root 必须是非 namespaced 的 `<svg>`,不能是 `<svg:svg>`。
|
||||
- root 必须声明 `xmlns:slide="https://slides.bytedance.com/ns"`。
|
||||
- root 必须包含 `slide:role="slide"`。
|
||||
- 可渲染元素必须有对应 `slide:role`:shape 使用 `slide:role="shape"`,图片使用 `slide:role="image"`。
|
||||
- `<g>` 和嵌套 `<svg>` 可以作为容器,用于继承样式和 transform;容器内真正渲染的元素仍必须声明 `slide:role`。
|
||||
- `slide:role="shape"` 目前只支持 `rect`、`ellipse`、`circle`、`line`、`path`、`foreignObject`。
|
||||
- 文本使用文本型 shape:`<foreignObject slide:role="shape" slide:shape-type="text">...</foreignObject>`。
|
||||
- 图片使用 `<image slide:role="image" href="file_token">`;本地占位符写成 `href="@./image.png"`。
|
||||
- `<defs>` 和 `<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
|
||||
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
|
||||
|
||||
## 坐标系与画布
|
||||
|
||||
- 当前 `slides +create-svg` 新建的 Lark Slides presentation 回读画布通常是 `960 x 540`。生成 SVG deck 时默认使用 `width="960" height="540" viewBox="0 0 960 540"`,不要默认用 `1280 x 720`。
|
||||
- 服务端不会保证把 `viewBox="0 0 1280 720"` 自动缩放到 `960 x 540`。如果用 1280x720 设计,必须在提交前整体换算到目标画布,或在回读 XML 后验证没有越界。
|
||||
- 生成时为所有主体元素预留安全区,建议 `x >= 48`、`y >= 40`、`right <= 912`、`bottom <= 500`。全屏背景可以铺满 `0,0,960,540`,但主体文字、图表和卡片仍应留在安全区内。
|
||||
- 回读 XML 后必须检查主体元素边界:非背景元素的 `topLeftX + width <= 960` 且 `topLeftY + height <= 540`。任何页面越界都视为生成失败,需要重排或缩放后重建。
|
||||
|
||||
## 几何必填属性
|
||||
|
||||
SVGlide leaf shape 必须显式写出服务端建模所需的几何属性,不依赖 SVG 默认值。缺失这些属性通常会被服务端包装成 `shape missing required attribute` 或 generic invalid param。
|
||||
|
||||
| Element | Required attributes |
|
||||
|---------|---------------------|
|
||||
| `rect slide:role="shape"` | `x`, `y`, `width`, `height` |
|
||||
| `foreignObject slide:role="shape" slide:shape-type="text"` | `x`, `y`, `width`, `height` |
|
||||
| `image slide:role="image"` | `href`, `x`, `y`, `width`, `height` |
|
||||
| `circle slide:role="shape"` | `cx`, `cy`, `r` |
|
||||
| `ellipse slide:role="shape"` | `cx`, `cy`, `rx`, `ry` |
|
||||
| `line slide:role="shape"` | `x1`, `y1`, `x2`, `y2` |
|
||||
| `path slide:role="shape"` | `d` |
|
||||
|
||||
这些属性即使取值为 `0` 也要写出来。例如背景图必须写成:
|
||||
|
||||
```xml
|
||||
<image slide:role="image" href="@./background.jpg" x="0" y="0" width="960" height="540" />
|
||||
```
|
||||
|
||||
CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px` 长度,例如 `0`、`1280`、`320.5`、`80px`。不要使用 `%`、`em`、`rem`、`calc(...)` 或省略单位后依赖 SVG 默认值。服务端可能会对部分非法几何值降级为 `0` 并给 warning,但正式生成应在 CLI 侧提前修正。
|
||||
|
||||
## 当前支持的 SVG 子集
|
||||
|
||||
- Shape: `rect`、`ellipse`、`circle`、`line`、`path`、`foreignObject`。
|
||||
- Container: `g`、嵌套 `svg`。
|
||||
- Definitions: `defs` 内的 `linearGradient`、`radialGradient`、`filter/feDropShadow`;支持嵌套 `defs` 和 gradient `href` / `xlink:href` 继承。
|
||||
- CSS: tag、`.class`、`#id`、`.a.b`、`tag.class` 等简单 selector;支持 specificity 和 source order;复杂 selector、media query、伪类会被忽略。
|
||||
- Paint: `fill`、`stroke`、`stroke-width`、`opacity`、`fill-opacity`、`stroke-opacity`、`stroke-dasharray`、`stroke-linecap`、`stroke-linejoin`。
|
||||
- Gradient: `stop-color` / `stop-opacity` 可来自属性、inline style 或 CSS;`gradientTransform`、`spreadMethod`、focal 点等复杂能力会被近似或忽略。
|
||||
- Effects: 支持 `filter="url(#...)"` 指向的 `feDropShadow`、CSS `filter: drop-shadow(...)`、以及首层 `box-shadow`;多层 shadow、spread、inset 会被近似或忽略。
|
||||
- Transform: `translate`、`scale`、`matrix`、`rotate`;transform 参数应写数字或 `px`,复杂 transform 会被近似或忽略。
|
||||
- Path: 只使用 `M/L/H/V/C/Q/Z`;CLI 会拒绝 arc `A`、smooth curve `S/T` 和其他未知命令。
|
||||
- Text: `foreignObject slide:shape-type="text"` 内支持常见 XHTML 文本标签、`br` 和基础文字样式。
|
||||
|
||||
文本样式应使用 parser 友好的显式 CSS 属性,例如 `font-size`、`font-weight`、`font-family`、`color`、`line-height`、`text-align`、`letter-spacing`。不要依赖 `font:` shorthand、复杂 flex 布局或浏览器默认样式来表达关键字号、加粗和行距;这些在转换到 SXSD/XML 时可能降级为默认样式。
|
||||
|
||||
## 不支持
|
||||
|
||||
- 不要把普通 SVG 直接交给 `+create-svg`,CLI 不会自动补齐 SVGlide 协议。
|
||||
- 不支持缺少 role 的可渲染元素,例如 `<rect .../>`;必须写成 `<rect slide:role="shape" .../>`。
|
||||
- 不要把 `<g>` 当作可渲染 shape;`<g>` 只是容器,实际 `rect`、`path`、`foreignObject`、`image` 等子元素仍需各自声明 `slide:role`。
|
||||
- 不支持根级 `<text slide:role="text">`;用 `foreignObject + slide:shape-type="text"`。
|
||||
- 不要在 `<image>` 上保留 `xlink:href`;CLI 会统一输出 canonical `href`。
|
||||
- 不要用 http(s) 或 data URL 外链图片;先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。
|
||||
- `slides +create-svg` MVP 不支持指定 `beforeSlideBlockID` 插入到某一页前;它创建新 presentation 后按 `--file` 顺序追加。
|
||||
|
||||
这些能力依赖 slide server SVGlide parser 新版本。如果 BOE/线上未部署对应 server 分支,CLI 放行后仍可能收到服务端 `SVGLIDE_ERROR_JSON` 或 generic invalid param。
|
||||
|
||||
## 图片与 Metadata
|
||||
|
||||
SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。宣传、产品、品牌、案例和视觉展示型 deck 至少应包含封面/半出血主视觉/案例场景/产品截图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路不可用时,才退回纯矢量方案,并在结果中说明原因。
|
||||
|
||||
图片资产还必须规避版权风险:只使用用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library,但每张图都必须检查具体 license。不要使用版权状态不明的搜索图片、新闻配图、第三方 logo、竞品官网截图或素材站预览图。生成 deck 时应在素材清单或 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时不得使用该图片。
|
||||
|
||||
`slides +create-svg` 会把 `<image href="@./image.png">` 上传为 file token,并注入:
|
||||
|
||||
```xml
|
||||
<metadata data-svglide-assets="true">
|
||||
<img src="boxcn..." />
|
||||
</metadata>
|
||||
```
|
||||
|
||||
metadata 只用于让现有服务端链路生成 `FileMetaMap`。如果使用 `--assets assets.json` 传入预上传 token,CLI 也会按同样规则替换和注入。
|
||||
|
||||
`assets.json` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"@./image.png": "boxcn...",
|
||||
"./other.png": "boxcn..."
|
||||
}
|
||||
```
|
||||
486
skills/lark-slides/scripts/svg_preflight.py
Normal file
486
skills/lark-slides/scripts/svg_preflight.py
Normal file
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SLIDE_NS = "https://slides.bytedance.com/ns"
|
||||
XLINK_NS = "http://www.w3.org/1999/xlink"
|
||||
SVG_NS = "http://www.w3.org/2000/svg"
|
||||
CANVAS_WIDTH = 960.0
|
||||
CANVAS_HEIGHT = 540.0
|
||||
SAFE_AREA = {"x": 48.0, "y": 40.0, "width": 864.0, "height": 460.0}
|
||||
|
||||
NUMBER_RE = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$")
|
||||
PATH_NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
|
||||
FONT_SHORTHAND_RE = re.compile(r"(^|;)\s*font\s*:", re.IGNORECASE)
|
||||
|
||||
SUPPORTED_SHAPES = {"rect", "ellipse", "circle", "line", "path", "foreignObject"}
|
||||
RENDERABLE_TAGS = SUPPORTED_SHAPES | {"image", "text", "polygon", "polyline"}
|
||||
IGNORED_SUBTREES = {"defs", "style"}
|
||||
|
||||
|
||||
class SvgPreflightError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise SvgPreflightError(message)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> dict[str, Any]:
|
||||
inputs: list[str] = []
|
||||
index = 0
|
||||
while index < len(argv):
|
||||
token = argv[index]
|
||||
if token in {"--input", "-i"}:
|
||||
if index + 1 >= len(argv):
|
||||
fail(f"{token} requires a file path")
|
||||
inputs.append(argv[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
if token.startswith("--"):
|
||||
fail(f"unexpected argument: {token}")
|
||||
inputs.append(token)
|
||||
index += 1
|
||||
if not inputs:
|
||||
fail("at least one --input <svg-file> is required")
|
||||
return {"inputs": inputs}
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
|
||||
|
||||
|
||||
def get_attr(element: ET.Element, name: str, namespace: str | None = None) -> str | None:
|
||||
if namespace:
|
||||
value = element.attrib.get(f"{{{namespace}}}{name}")
|
||||
if value is not None:
|
||||
return value
|
||||
value = element.attrib.get(name)
|
||||
if value is not None:
|
||||
return value
|
||||
for key, candidate in element.attrib.items():
|
||||
if key.endswith("}" + name):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def svg_role(element: ET.Element) -> str | None:
|
||||
return get_attr(element, "role", SLIDE_NS)
|
||||
|
||||
|
||||
def svg_shape_type(element: ET.Element) -> str | None:
|
||||
return get_attr(element, "shape-type", SLIDE_NS)
|
||||
|
||||
|
||||
def parse_number(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not NUMBER_RE.match(value):
|
||||
return None
|
||||
if value.lower().endswith("px"):
|
||||
value = value[:-2]
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_required_number(element: ET.Element, name: str) -> float | None:
|
||||
return parse_number(get_attr(element, name))
|
||||
|
||||
|
||||
def issue(level: str, code: str, message: str, element: ET.Element | None = None, hint: str | None = None) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"level": level, "code": code, "message": message}
|
||||
if element is not None:
|
||||
elem_id = get_attr(element, "id")
|
||||
if elem_id:
|
||||
out["element_id"] = elem_id
|
||||
out["tag"] = local_name(element.tag)
|
||||
if hint:
|
||||
out["hint"] = hint
|
||||
return out
|
||||
|
||||
|
||||
def parse_viewbox(value: str | None) -> list[float] | None:
|
||||
if value is None:
|
||||
return None
|
||||
parts = [part for part in re.split(r"[\s,]+", value.strip()) if part]
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
try:
|
||||
return [float(part) for part in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_external_href(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
lower = value.strip().lower()
|
||||
return lower.startswith("http://") or lower.startswith("https://") or lower.startswith("data:")
|
||||
|
||||
|
||||
def href_value(element: ET.Element) -> str | None:
|
||||
return get_attr(element, "href") or get_attr(element, "href", XLINK_NS)
|
||||
|
||||
|
||||
def walk_renderable(root: ET.Element) -> list[ET.Element]:
|
||||
out: list[ET.Element] = []
|
||||
|
||||
def walk(element: ET.Element) -> None:
|
||||
name = local_name(element.tag)
|
||||
if name in IGNORED_SUBTREES:
|
||||
return
|
||||
if name in RENDERABLE_TAGS or name == "foreignObject" or name == "image":
|
||||
out.append(element)
|
||||
for child in list(element):
|
||||
walk(child)
|
||||
|
||||
for child in list(root):
|
||||
walk(child)
|
||||
return out
|
||||
|
||||
|
||||
def validate_root(root: ET.Element) -> tuple[list[dict[str, Any]], float, float]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
if local_name(root.tag) != "svg":
|
||||
issues.append(issue("error", "root_not_svg", "root element must be <svg>"))
|
||||
if svg_role(root) != "slide":
|
||||
issues.append(issue("error", "missing_root_role", 'root <svg> must include slide:role="slide"', root))
|
||||
|
||||
width = parse_number(get_attr(root, "width"))
|
||||
height = parse_number(get_attr(root, "height"))
|
||||
viewbox = parse_viewbox(get_attr(root, "viewBox"))
|
||||
|
||||
if width != CANVAS_WIDTH or height != CANVAS_HEIGHT:
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"root_canvas_mismatch",
|
||||
'root must use width="960" height="540"',
|
||||
root,
|
||||
"Scale coordinates to the Lark Slides 960x540 canvas before calling slides +create-svg.",
|
||||
)
|
||||
)
|
||||
if viewbox != [0.0, 0.0, CANVAS_WIDTH, CANVAS_HEIGHT]:
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"root_viewbox_mismatch",
|
||||
'root must use viewBox="0 0 960 540"',
|
||||
root,
|
||||
"Do not submit a 1280x720 viewBox and rely on server-side scaling.",
|
||||
)
|
||||
)
|
||||
|
||||
return issues, width or CANVAS_WIDTH, height or CANVAS_HEIGHT
|
||||
|
||||
|
||||
def validate_roles_and_attrs(elements: list[ET.Element]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
name = local_name(element.tag)
|
||||
role = svg_role(element)
|
||||
if name == "text":
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"unsupported_text_element",
|
||||
'root-level <text> is not supported; use foreignObject slide:role="shape" slide:shape-type="text"',
|
||||
element,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if name in {"polygon", "polyline"}:
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"unsupported_shape_element",
|
||||
f"<{name}> is not supported by SVGlide MVP",
|
||||
element,
|
||||
"Use path with M/L/H/V/C/Q/Z commands, or use rect/line/circle/ellipse.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if name not in {"image"} | SUPPORTED_SHAPES:
|
||||
continue
|
||||
if role is None:
|
||||
issues.append(issue("error", "missing_leaf_role", '<%s> must include slide:role="shape" or "image"' % name, element))
|
||||
continue
|
||||
if role == "shape":
|
||||
if name not in SUPPORTED_SHAPES:
|
||||
issues.append(issue("error", "unsupported_shape_role", f'<{name} slide:role="shape"> is not supported', element))
|
||||
continue
|
||||
if name == "foreignObject" and svg_shape_type(element) != "text":
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"missing_text_shape_type",
|
||||
'<foreignObject slide:role="shape"> must include slide:shape-type="text"',
|
||||
element,
|
||||
)
|
||||
)
|
||||
elif role == "image":
|
||||
if name != "image":
|
||||
issues.append(issue("error", "unsupported_image_role", f'<{name} slide:role="image"> is not supported', element))
|
||||
image_href = href_value(element)
|
||||
if not image_href:
|
||||
issues.append(issue("error", "missing_image_href", '<image slide:role="image"> must include href', element))
|
||||
if is_external_href(image_href):
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"external_image_href",
|
||||
"<image> must not use http(s) or data href",
|
||||
element,
|
||||
'Download or generate the image locally and use href="@./path", or provide a file token.',
|
||||
)
|
||||
)
|
||||
else:
|
||||
issues.append(issue("error", "unsupported_role", f'unsupported slide:role="{role}"', element))
|
||||
return issues
|
||||
|
||||
|
||||
def bbox_for_element(element: ET.Element) -> dict[str, float] | None:
|
||||
name = local_name(element.tag)
|
||||
if name in {"rect", "foreignObject", "image"}:
|
||||
x = parse_required_number(element, "x")
|
||||
y = parse_required_number(element, "y")
|
||||
width = parse_required_number(element, "width")
|
||||
height = parse_required_number(element, "height")
|
||||
if None in {x, y, width, height}:
|
||||
return None
|
||||
return {"x": x or 0.0, "y": y or 0.0, "width": width or 0.0, "height": height or 0.0}
|
||||
if name == "circle":
|
||||
cx = parse_required_number(element, "cx")
|
||||
cy = parse_required_number(element, "cy")
|
||||
r = parse_required_number(element, "r")
|
||||
if None in {cx, cy, r}:
|
||||
return None
|
||||
return {"x": (cx or 0.0) - (r or 0.0), "y": (cy or 0.0) - (r or 0.0), "width": 2 * (r or 0.0), "height": 2 * (r or 0.0)}
|
||||
if name == "ellipse":
|
||||
cx = parse_required_number(element, "cx")
|
||||
cy = parse_required_number(element, "cy")
|
||||
rx = parse_required_number(element, "rx")
|
||||
ry = parse_required_number(element, "ry")
|
||||
if None in {cx, cy, rx, ry}:
|
||||
return None
|
||||
return {"x": (cx or 0.0) - (rx or 0.0), "y": (cy or 0.0) - (ry or 0.0), "width": 2 * (rx or 0.0), "height": 2 * (ry or 0.0)}
|
||||
if name == "line":
|
||||
x1 = parse_required_number(element, "x1")
|
||||
y1 = parse_required_number(element, "y1")
|
||||
x2 = parse_required_number(element, "x2")
|
||||
y2 = parse_required_number(element, "y2")
|
||||
if None in {x1, y1, x2, y2}:
|
||||
return None
|
||||
min_x = min(x1 or 0.0, x2 or 0.0)
|
||||
min_y = min(y1 or 0.0, y2 or 0.0)
|
||||
return {"x": min_x, "y": min_y, "width": abs((x2 or 0.0) - (x1 or 0.0)), "height": abs((y2 or 0.0) - (y1 or 0.0))}
|
||||
return None
|
||||
|
||||
|
||||
def is_background_bbox(bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool:
|
||||
return bbox["x"] <= 0 and bbox["y"] <= 0 and bbox["x"] + bbox["width"] >= canvas_width and bbox["y"] + bbox["height"] >= canvas_height
|
||||
|
||||
|
||||
def bbox_outside(bbox: dict[str, float], rect: dict[str, float]) -> bool:
|
||||
return (
|
||||
bbox["x"] < rect["x"]
|
||||
or bbox["y"] < rect["y"]
|
||||
or bbox["x"] + bbox["width"] > rect["x"] + rect["width"]
|
||||
or bbox["y"] + bbox["height"] > rect["y"] + rect["height"]
|
||||
)
|
||||
|
||||
|
||||
def intersects(left: dict[str, float], right: dict[str, float]) -> bool:
|
||||
return (
|
||||
left["x"] < right["x"] + right["width"]
|
||||
and left["x"] + left["width"] > right["x"]
|
||||
and left["y"] < right["y"] + right["height"]
|
||||
and left["y"] + left["height"] > right["y"]
|
||||
)
|
||||
|
||||
|
||||
def validate_geometry(elements: list[ET.Element], canvas_width: float, canvas_height: float) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
text_boxes: list[dict[str, Any]] = []
|
||||
canvas = {"x": 0.0, "y": 0.0, "width": canvas_width, "height": canvas_height}
|
||||
for element in elements:
|
||||
name = local_name(element.tag)
|
||||
bbox = bbox_for_element(element)
|
||||
if bbox is None:
|
||||
continue
|
||||
if is_background_bbox(bbox, canvas_width, canvas_height):
|
||||
continue
|
||||
if bbox_outside(bbox, canvas):
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"canvas_bounds",
|
||||
f"<{name}> is outside the 960x540 canvas",
|
||||
element,
|
||||
"Non-background elements must fit inside the slide canvas.",
|
||||
)
|
||||
)
|
||||
elif bbox_outside(bbox, SAFE_AREA):
|
||||
issues.append(
|
||||
issue(
|
||||
"warning",
|
||||
"safe_area",
|
||||
f"<{name}> is outside the recommended safe area",
|
||||
element,
|
||||
"Keep text, labels, cards, legends, and key visuals within x>=48 y>=40 right<=912 bottom<=500 unless it is an intentional full-bleed background.",
|
||||
)
|
||||
)
|
||||
if name == "foreignObject" and svg_role(element) == "shape" and svg_shape_type(element) == "text":
|
||||
text = "".join(element.itertext()).strip()
|
||||
if text:
|
||||
text_boxes.append({"element": element, "bbox": bbox, "text": text})
|
||||
return issues, text_boxes
|
||||
|
||||
|
||||
def validate_text_overlap(text_boxes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for left_index, left in enumerate(text_boxes):
|
||||
for right in text_boxes[left_index + 1 :]:
|
||||
if intersects(left["bbox"], right["bbox"]):
|
||||
left_id = get_attr(left["element"], "id") or local_name(left["element"].tag)
|
||||
right_id = get_attr(right["element"], "id") or local_name(right["element"].tag)
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "text_bbox_overlap",
|
||||
"message": f"text boxes overlap: {left_id} and {right_id}",
|
||||
"left_element_id": get_attr(left["element"], "id"),
|
||||
"right_element_id": get_attr(right["element"], "id"),
|
||||
"hint": "Move text boxes apart, reduce text density, or split the page into clearer layout boxes.",
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def validate_styles(root: ET.Element) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in root.iter():
|
||||
style = get_attr(element, "style") or ""
|
||||
if FONT_SHORTHAND_RE.search(style):
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"font_shorthand",
|
||||
'style must not use "font:" shorthand',
|
||||
element,
|
||||
"Use explicit font-size, font-weight, font-family, color, line-height, and text-align properties.",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def validate_paths(elements: list[ET.Element]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in elements:
|
||||
if local_name(element.tag) != "path" or svg_role(element) != "shape":
|
||||
continue
|
||||
data = get_attr(element, "d") or ""
|
||||
without_numbers = PATH_NUMBER_RE.sub("", data)
|
||||
has_command = False
|
||||
for char in without_numbers:
|
||||
if char in ", \t\r\n":
|
||||
continue
|
||||
if char in "MLHVZCQmlhvzcq":
|
||||
has_command = True
|
||||
continue
|
||||
issues.append(
|
||||
issue(
|
||||
"error",
|
||||
"unsupported_path_command",
|
||||
f'unsupported path command or character "{char}"',
|
||||
element,
|
||||
"Use only M/L/H/V/C/Q/Z path commands.",
|
||||
)
|
||||
)
|
||||
break
|
||||
if not has_command:
|
||||
issues.append(issue("error", "missing_path_command", 'path attribute "d" must include M/L/H/V/C/Q/Z commands', element))
|
||||
return issues
|
||||
|
||||
|
||||
def lint_svg(svg: str, path: str = "<svg>") -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"path": path, "issues": []}
|
||||
try:
|
||||
root = ET.fromstring(svg)
|
||||
except ET.ParseError as error:
|
||||
result["issues"].append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "xml_not_well_formed",
|
||||
"message": f"SVG is not well-formed: {error}",
|
||||
"hint": "Fix tag closure, attribute quotes, namespaces, and XML escaping before calling slides +create-svg.",
|
||||
}
|
||||
)
|
||||
result["summary"] = {"error_count": 1, "warning_count": 0}
|
||||
return result
|
||||
|
||||
root_issues, width, height = validate_root(root)
|
||||
elements = walk_renderable(root)
|
||||
role_issues = validate_roles_and_attrs(elements)
|
||||
geometry_issues, text_boxes = validate_geometry(elements, width, height)
|
||||
issues = root_issues + role_issues + validate_styles(root) + validate_paths(elements) + geometry_issues + validate_text_overlap(text_boxes)
|
||||
|
||||
result["width"] = width
|
||||
result["height"] = height
|
||||
result["element_count"] = len(elements)
|
||||
result["text_box_count"] = len(text_boxes)
|
||||
result["issues"] = issues
|
||||
result["summary"] = {
|
||||
"error_count": sum(1 for item in issues if item["level"] == "error"),
|
||||
"warning_count": sum(1 for item in issues if item["level"] == "warning"),
|
||||
}
|
||||
if not issues:
|
||||
result.pop("issues")
|
||||
return result
|
||||
|
||||
|
||||
def lint_files(paths: list[str]) -> dict[str, Any]:
|
||||
files: list[dict[str, Any]] = []
|
||||
for path in paths:
|
||||
svg = Path(path).read_text(encoding="utf-8")
|
||||
files.append(lint_svg(svg, path))
|
||||
return {
|
||||
"summary": {
|
||||
"file_count": len(files),
|
||||
"error_count": sum(file["summary"]["error_count"] for file in files),
|
||||
"warning_count": sum(file["summary"]["warning_count"] for file in files),
|
||||
},
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
options = parse_args(argv)
|
||||
result = lint_files(options["inputs"])
|
||||
except SvgPreflightError as error:
|
||||
print(f"svg_preflight: {error}", file=sys.stderr)
|
||||
return 2
|
||||
except OSError as error:
|
||||
print(f"svg_preflight: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 1 if result["summary"]["error_count"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
97
skills/lark-slides/scripts/svg_preflight_test.py
Normal file
97
skills/lark-slides/scripts/svg_preflight_test.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import svg_preflight
|
||||
|
||||
|
||||
VALID_SVG = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide"
|
||||
width="960" height="540" viewBox="0 0 960 540">
|
||||
<rect slide:role="shape" x="0" y="0" width="960" height="540" fill="#f8fafc" />
|
||||
<foreignObject id="title" slide:role="shape" slide:shape-type="text" x="64" y="56" width="420" height="72">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"
|
||||
style="font-size:32px;font-weight:800;font-family:Arial;color:#111827;line-height:1.15;text-align:left;">
|
||||
Strategy review
|
||||
</div>
|
||||
</foreignObject>
|
||||
<image id="hero" slide:role="image" href="@./assets/hero.jpg" x="560" y="96" width="320" height="220" />
|
||||
<path id="trend" slide:role="shape" d="M64 360 L180 330 C260 300 340 340 420 300 Q500 260 580 290" fill="none" stroke="#2563eb" />
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
class SvgPreflightTest(unittest.TestCase):
|
||||
def test_lint_svg_accepts_valid_svglide(self) -> None:
|
||||
result = svg_preflight.lint_svg(VALID_SVG)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_svg_reports_canvas_mismatch(self) -> None:
|
||||
result = svg_preflight.lint_svg(
|
||||
VALID_SVG.replace('width="960" height="540" viewBox="0 0 960 540"', 'width="1280" height="720" viewBox="0 0 1280 720"')
|
||||
)
|
||||
codes = [issue["code"] for issue in result["issues"]]
|
||||
self.assertIn("root_canvas_mismatch", codes)
|
||||
self.assertIn("root_viewbox_mismatch", codes)
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
|
||||
def test_lint_svg_reports_external_image_and_font_shorthand(self) -> None:
|
||||
svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide"
|
||||
width="960" height="540" viewBox="0 0 960 540">
|
||||
<foreignObject id="title" slide:role="shape" slide:shape-type="text" x="64" y="56" width="420" height="72">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="font: 700 24px Arial;color:#111827;">Title</div>
|
||||
</foreignObject>
|
||||
<image id="hero" slide:role="image" href="https://example.com/hero.jpg" x="560" y="96" width="320" height="220" />
|
||||
</svg>
|
||||
"""
|
||||
result = svg_preflight.lint_svg(svg)
|
||||
codes = [issue["code"] for issue in result["issues"]]
|
||||
self.assertIn("external_image_href", codes)
|
||||
self.assertIn("font_shorthand", codes)
|
||||
|
||||
def test_lint_svg_reports_canvas_error_and_safe_area_warning(self) -> None:
|
||||
svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide"
|
||||
width="960" height="540" viewBox="0 0 960 540">
|
||||
<rect id="badge" slide:role="shape" x="12" y="20" width="80" height="40" />
|
||||
<rect id="overflow" slide:role="shape" x="920" y="500" width="120" height="80" />
|
||||
</svg>
|
||||
"""
|
||||
result = svg_preflight.lint_svg(svg)
|
||||
codes = [issue["code"] for issue in result["issues"]]
|
||||
self.assertIn("safe_area", codes)
|
||||
self.assertIn("canvas_bounds", codes)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 1)
|
||||
|
||||
def test_lint_svg_reports_text_bbox_overlap(self) -> None:
|
||||
svg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide"
|
||||
width="960" height="540" viewBox="0 0 960 540">
|
||||
<foreignObject id="a" slide:role="shape" slide:shape-type="text" x="80" y="80" width="240" height="80">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:24px;font-weight:700;color:#111;">A</div>
|
||||
</foreignObject>
|
||||
<foreignObject id="b" slide:role="shape" slide:shape-type="text" x="120" y="100" width="240" height="80">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:18px;font-weight:400;color:#111;">B</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
"""
|
||||
result = svg_preflight.lint_svg(svg)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["issues"][0]["code"], "text_bbox_overlap")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,17 +1,25 @@
|
||||
# Slides CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 2 leaf commands
|
||||
- Covered: 1
|
||||
- Denominator: 4 leaf commands
|
||||
- Covered: 2
|
||||
- Coverage: 50.0%
|
||||
|
||||
## Summary
|
||||
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
|
||||
- TestSlidesCreateSVGDryRunRequestShape: locks the `slides +create-svg --dry-run` request chain and recursive SVGlide validation, including `g` containers, geometry-required leaves, `px` geometry, nested defs/filter, shadow style preservation, and `foreignObject` XHTML `<br />`.
|
||||
- TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute: proves CLI blocks leaf shapes that would otherwise reach the server as `shape missing required attribute`.
|
||||
- TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry: proves CLI blocks non-absolute geometry before issuing API calls.
|
||||
- TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand: proves CLI blocks unsupported path commands before issuing API calls.
|
||||
- TestSlidesCreateSVGWorkflowAsUser: opt-in live workflow for `slides +create-svg` (`LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1`); creates a two-page SVG deck as user, asserts returned page count and IDs, then reads the presentation back.
|
||||
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
|
||||
- Blocked area: `slides +replace-slide` has focused unit coverage but no E2E workflow yet.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
|
||||
| ✓ | slides +create-svg | shortcut | slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRequestShape; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingChildRole; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand; slides_create_svg_live_test.go::TestSlidesCreateSVGWorkflowAsUser | repeated `--file`; `--title`; `--dry-run` | dry-run proves existing `/slide` route, `slide.content`, recursive SVGlide validation, server-known hard failures, numeric geometry gates, and path command gates before API call; live is opt-in and depends on the target server lane containing the updated SVGlide parser |
|
||||
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |
|
||||
| ✕ | slides +replace-slide | shortcut | | none | unit-covered shortcut; E2E workflow still pending |
|
||||
|
||||
159
tests/cli_e2e/slides/slides_create_svg_dryrun_test.go
Normal file
159
tests/cli_e2e/slides/slides_create_svg_dryrun_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setSlidesDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRequestShape(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><style>.primary{fill:#123456;stroke:#654321;stroke-width:2px;filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2))}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><g transform="translate(20 30)"><rect slide:role="shape" class="primary" x="0" y="0" width="320px" height="180px" filter="url(#shadow)"/></g></svg>`), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><div xmlns="http://www.w3.org/1999/xhtml">two<br />lines</div></foreignObject></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", "Dry SVG",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<rect slide:role="shape"`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<g transform="translate(20 30)">`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<style>.primary`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<feDropShadow`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `slide:shape-type="text"`, "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `<br />`, "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsMissingChildRole(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect x="80" y="80" width="320" height="180"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect> must include slide:role="shape" or slide:role="image"`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" height="180"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect slide:role="shape"> missing required attribute "width"`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="50%" height="180"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `attribute "width" must be a number or px length`)
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20"/></svg>`), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "bad.svg",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `unsupported path command or character "A"`)
|
||||
}
|
||||
115
tests/cli_e2e/slides/slides_create_svg_live_test.go
Normal file
115
tests/cli_e2e/slides/slides_create_svg_live_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
if os.Getenv("LARKSUITE_CLI_RUN_SVGLIDE_LIVE") != "1" {
|
||||
t.Skip("set LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1 to run the live SVGlide service contract test")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><g fill="#E8EEF8" transform="translate(80 80)"><rect slide:role="shape" x="0" y="0" width="360" height="180"/></g></svg>`), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="120" y="120" width="420" height="100"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide live E2E</p></foreignObject></svg>`), 0o644))
|
||||
|
||||
parentT := t
|
||||
title := "svglide-e2e-" + clie2e.GenerateSuffix()
|
||||
var presentationID string
|
||||
|
||||
t.Run("create SVG deck as user", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+create-svg",
|
||||
"--file", "page1.svg",
|
||||
"--file", "page2.svg",
|
||||
"--title", title,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
if created := extractSVGlideFailurePresentationID(result.Stderr); created != "" {
|
||||
presentationID = created
|
||||
registerSlidesCleanup(parentT, presentationID)
|
||||
}
|
||||
}
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
|
||||
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, title, gjson.Get(result.Stdout, "data.title").String(), "stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.slides_added").Int(), "stdout:\n%s", result.Stdout)
|
||||
require.Len(t, gjson.Get(result.Stdout, "data.slide_ids").Array(), 2, "stdout:\n%s", result.Stdout)
|
||||
|
||||
registerSlidesCleanup(parentT, presentationID)
|
||||
})
|
||||
|
||||
t.Run("read back SVG-created presentation as user", func(t *testing.T) {
|
||||
require.NotEmpty(t, presentationID, "presentation should be created before readback")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
|
||||
DefaultAs: "user",
|
||||
Params: map[string]any{"revision_id": -1},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
require.Equal(t, presentationID, gjson.Get(result.Stdout, "data.xml_presentation.presentation_id").String(), "stdout:\n%s", result.Stdout)
|
||||
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
|
||||
require.Contains(t, content, "<title>"+title+"</title>", "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
|
||||
func registerSlidesCleanup(t *testing.T, presentationID string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+delete",
|
||||
"--file-token", presentationID,
|
||||
"--type", "slides",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, "delete presentation "+presentationID, deleteResult, deleteErr)
|
||||
})
|
||||
}
|
||||
|
||||
func extractSVGlideFailurePresentationID(stderr string) string {
|
||||
const marker = "presentation "
|
||||
idx := strings.Index(stderr, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := stderr[idx+len(marker):]
|
||||
end := strings.IndexByte(rest, ' ')
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Trim(rest[:end], ".,;:)")
|
||||
}
|
||||
Reference in New Issue
Block a user