mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
16 Commits
codex/cli-
...
v1.0.77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 | ||
|
|
4807283368 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
@@ -1638,6 +1667,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
|
||||
23
README.md
23
README.md
@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
23
README.zh.md
23
README.zh.md
@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
|
||||
|
||||
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
|
||||
|
||||
为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
|
||||
|
||||
- 操作系统类型:macOS、Windows 或 Linux
|
||||
- 设备的硬件产品型号:例如 Mac17,9
|
||||
|
||||
如需让当前 workspace 退出该保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
如需开启当前 workspace 的保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
恢复当前 workspace 默认策略可执行:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
|
||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "risk-control [on|off|default]",
|
||||
Short: "Manage workspace account-protection policy",
|
||||
Long: `View or set the account-protection risk-control policy for this workspace.
|
||||
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
// This is persistent workspace policy, not credential management.
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printRiskControl(f, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "on":
|
||||
enabled := true
|
||||
config.RiskControl = &enabled
|
||||
case "off":
|
||||
enabled := false
|
||||
config.RiskControl = &enabled
|
||||
case "default":
|
||||
config.RiskControl = nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
|
||||
}
|
||||
|
||||
func riskControlState(enabled bool) string {
|
||||
if enabled {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
130
cmd/config/risk_control_test.go
Normal file
130
cmd/config/risk_control_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "set to off") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || !*loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"default"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl != nil {
|
||||
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show default: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"invalid"})
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"risk-control", "off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
@@ -33,7 +34,7 @@ import (
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential and workspace policy
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
f := &Factory{
|
||||
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
// Phase 4: LarkClient composes account data and workspace policy at the SDK
|
||||
// transport boundary.
|
||||
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
var sdkBase http.RoundTripper = transport.Shared()
|
||||
// The innermost SDK boundary always strips reserved host-signal headers;
|
||||
// a nil source makes it strip-only when workspace policy disables signal
|
||||
// collection.
|
||||
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
|
||||
sdkTransport := wrapSDKTransport(sdkBase)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
|
||||
@@ -6,10 +6,15 @@ package cmdutil
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
f.IOStreams.StderrIsTerminal = tc.terminal
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
// normalizeStreams copies the struct (out := *s), so the
|
||||
// StderrIsTerminal field survives into f.IOStreams.
|
||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
||||
if _, err := cachedLarkClientFunc(f)(); err != nil {
|
||||
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
|
||||
36
internal/cmdutil/localfile.go
Normal file
36
internal/cmdutil/localfile.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// StatLocalFile returns metadata for a path in the process filesystem namespace.
|
||||
// It is intended for advisory validation; callers must validate the opened file
|
||||
// again before using its contents.
|
||||
func StatLocalFile(path string) (fs.FileInfo, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Stat(localPath)
|
||||
}
|
||||
|
||||
// OpenLocalFile opens a path in the process filesystem namespace.
|
||||
// Absolute and relative paths are accepted. It is the shared replacement for
|
||||
// direct os.Open/os.ReadFile use in commands that intentionally read local
|
||||
// paths outside the workspace sandbox. Callers inspect the returned descriptor
|
||||
// before reading so validation and use apply to the same opened file.
|
||||
func OpenLocalFile(path string) (fs.File, error) {
|
||||
localPath, err := validate.LocalInputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Open(localPath)
|
||||
}
|
||||
96
internal/cmdutil/localfile_test.go
Normal file
96
internal/cmdutil/localfile_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
TestChdir(t, workDir)
|
||||
|
||||
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
|
||||
f, err := OpenLocalFile(input)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
|
||||
}
|
||||
got, readErr := io.ReadAll(f)
|
||||
closeErr := f.Close()
|
||||
if readErr != nil || closeErr != nil || string(got) != "content" {
|
||||
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
|
||||
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
|
||||
info, err := StatLocalFile(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("StatLocalFile() error = %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previous := vfs.DefaultFS
|
||||
counting := &countingLocalFileFS{FS: previous}
|
||||
vfs.DefaultFS = counting
|
||||
t.Cleanup(func() { vfs.DefaultFS = previous })
|
||||
|
||||
f, err := OpenLocalFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLocalFile() error = %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counting.openCalls != 1 || counting.statCalls != 0 {
|
||||
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type countingLocalFileFS struct {
|
||||
vfs.FS
|
||||
openCalls int
|
||||
statCalls int
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
|
||||
f.openCalls++
|
||||
return f.FS.Open(name)
|
||||
}
|
||||
|
||||
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
|
||||
f.statCalls++
|
||||
return f.FS.Stat(name)
|
||||
}
|
||||
28
internal/cmdutil/risk_control.go
Normal file
28
internal/cmdutil/risk_control.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
)
|
||||
|
||||
type workspaceConfigSource interface {
|
||||
MultiAppConfig() (*core.MultiAppConfig, error)
|
||||
}
|
||||
|
||||
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
|
||||
// boundary.
|
||||
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, configErr := config.MultiAppConfig()
|
||||
// Default-on means an existing config with no explicit preference. Absent
|
||||
// or unreadable config cannot authorize host-signal collection.
|
||||
if configErr != nil || !workspace.RiskControlEnabled() {
|
||||
return nil
|
||||
}
|
||||
return riskcontrol.NewHostSource()
|
||||
}
|
||||
45
internal/cmdutil/risk_control_test.go
Normal file
45
internal/cmdutil/risk_control_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type staticWorkspaceConfig struct {
|
||||
config *core.MultiAppConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
|
||||
return s.config, s.err
|
||||
}
|
||||
|
||||
func TestResolveSDKHostSignalSource(t *testing.T) {
|
||||
disabled := false
|
||||
tests := []struct {
|
||||
name string
|
||||
config workspaceConfigSource
|
||||
wantSource bool
|
||||
}{
|
||||
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
|
||||
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
|
||||
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
|
||||
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
|
||||
{name: "nil config value", config: staticWorkspaceConfig{}},
|
||||
{name: "nil config source"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := resolveSDKHostSignalSource(test.config)
|
||||
if (got != nil) != test.wantSource {
|
||||
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSDKTransport chain composition
|
||||
// wrapSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
|
||||
return nil
|
||||
}
|
||||
|
||||
type riskHeaderTamperingInterceptor struct{}
|
||||
|
||||
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
var received http.Header
|
||||
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
|
||||
t.Fatalf("extension risk headers reached network: %v", received)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by buildSDKTransport.
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
|
||||
@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
RiskControl *bool `json:"riskControl,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// RiskControlEnabled resolves the workspace policy. An omitted preference
|
||||
// keeps the default-on account-protection behavior.
|
||||
func (m *MultiAppConfig) RiskControlEnabled() bool {
|
||||
return m != nil && (m.RiskControl == nil || *m.RiskControl)
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
|
||||
37
internal/core/config_snapshot.go
Normal file
37
internal/core/config_snapshot.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
|
||||
// invocation. All runtime consumers share the same load result so account and
|
||||
// workspace policy resolution cannot observe different file revisions. Callers
|
||||
// must treat the returned config as read-only.
|
||||
type ConfigSnapshot struct {
|
||||
load func() (*MultiAppConfig, error)
|
||||
}
|
||||
|
||||
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
|
||||
func NewConfigSnapshot() *ConfigSnapshot {
|
||||
return newConfigSnapshot(LoadMultiAppConfig)
|
||||
}
|
||||
|
||||
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
|
||||
if load == nil {
|
||||
return &ConfigSnapshot{}
|
||||
}
|
||||
return &ConfigSnapshot{load: sync.OnceValues(load)}
|
||||
}
|
||||
|
||||
// MultiAppConfig returns the captured persistent config and load error.
|
||||
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
|
||||
if s == nil || s.load == nil {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return s.load()
|
||||
}
|
||||
58
internal/core/config_snapshot_test.go
Normal file
58
internal/core/config_snapshot_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigSnapshotLoadsOnce(t *testing.T) {
|
||||
calls := 0
|
||||
want := &MultiAppConfig{}
|
||||
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
calls++
|
||||
return want, nil
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
config, err := snapshot.MultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config != want {
|
||||
t.Fatal("snapshot returned a different config instance")
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("config loads = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
|
||||
config, err := (&ConfigSnapshot{}).MultiAppConfig()
|
||||
if config != nil || !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSnapshotCachesError(t *testing.T) {
|
||||
calls := 0
|
||||
want := errors.New("load failed")
|
||||
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
|
||||
calls++
|
||||
return nil, want
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
config, err := snapshot.MultiAppConfig()
|
||||
if config != nil || !errors.Is(err, want) {
|
||||
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("config loads = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
disabled := false
|
||||
config := &MultiAppConfig{
|
||||
RiskControl: &disabled,
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
||||
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
||||
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
if got.Apps[0].Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
if got.RiskControl == nil || *got.RiskControl {
|
||||
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
|
||||
142
internal/riskcontrol/osmodel.go
Normal file
142
internal/riskcontrol/osmodel.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deviceinfo collects the platform hardware product model and the
|
||||
// platform values used by device-related risk-control headers.
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// OSType is the server-side risk-control operating-system enum.
|
||||
type OSType string
|
||||
|
||||
// OS type enum values for X-Agent-Os-Type.
|
||||
const (
|
||||
OSTypeUnknown = "0"
|
||||
OSTypeWindows = "1"
|
||||
OSTypeLinux = "2"
|
||||
OSTypeMacOS = "3"
|
||||
)
|
||||
|
||||
const (
|
||||
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
|
||||
TerminalTypePC = "1"
|
||||
|
||||
// Unknown is used when the hardware product model cannot be collected.
|
||||
Unknown = "Unknown"
|
||||
|
||||
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
|
||||
// Device models are short identifiers; a larger value is treated as
|
||||
// malformed rather than truncated so the header never misrepresents it.
|
||||
deviceModelMaxBytes = 256
|
||||
)
|
||||
|
||||
// Snapshot contains the deliberately small risk-control signal set.
|
||||
// ProductModel is omitted when the platform cannot provide a safe value.
|
||||
type Snapshot struct {
|
||||
OSType OSType
|
||||
ProductModel string
|
||||
}
|
||||
|
||||
// Source supplies one immutable process-level snapshot.
|
||||
type Source interface {
|
||||
Snapshot() Snapshot
|
||||
}
|
||||
|
||||
// HostSource lazily reads host signals once, after outbound policy authorizes
|
||||
// the first request. Failed probes are cached and are not retried per request.
|
||||
type HostSource struct {
|
||||
once sync.Once
|
||||
value Snapshot
|
||||
readModel func() string
|
||||
}
|
||||
|
||||
// NewHostSource creates the production host signal source.
|
||||
func NewHostSource() *HostSource {
|
||||
return &HostSource{readModel: readDeviceModel}
|
||||
}
|
||||
|
||||
// Snapshot returns the cached host signal snapshot.
|
||||
func (s *HostSource) Snapshot() Snapshot {
|
||||
if s == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
s.once.Do(func() {
|
||||
readModel := s.readModel
|
||||
if readModel == nil {
|
||||
readModel = readDeviceModel
|
||||
}
|
||||
s.value = Snapshot{
|
||||
OSType: GetOSType(OSName()),
|
||||
ProductModel: normalizeDeviceModel(readModel()),
|
||||
}
|
||||
})
|
||||
return s.value
|
||||
}
|
||||
|
||||
// normalizeModel removes non-printable characters and returns a model only
|
||||
// when the remaining text is safe to use as an HTTP header value. Input that
|
||||
// cannot produce a valid model is rejected so Get can fall back to Unknown.
|
||||
func normalizeDeviceModel(model string) string {
|
||||
if !utf8.ValidString(model) {
|
||||
return ""
|
||||
}
|
||||
model = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r == '\r' || r == '\n' || r == '\x00':
|
||||
return -1
|
||||
case unicode.IsSpace(r):
|
||||
return ' '
|
||||
case unicode.IsPrint(r):
|
||||
return r
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, model)
|
||||
|
||||
model = strings.Join(strings.Fields(model), " ")
|
||||
|
||||
if model == "" || len(model) > deviceModelMaxBytes {
|
||||
return ""
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(model) {
|
||||
return ""
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
|
||||
func GetOSType(osName string) OSType {
|
||||
switch osName {
|
||||
case "Windows":
|
||||
return OSTypeWindows
|
||||
case "Linux":
|
||||
return OSTypeLinux
|
||||
case "MacOS":
|
||||
return OSTypeMacOS
|
||||
default:
|
||||
return OSTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// OSName returns the platform name used by GetOSType.
|
||||
func OSName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "MacOS"
|
||||
case "windows":
|
||||
return "Windows"
|
||||
case "linux":
|
||||
return "Linux"
|
||||
default:
|
||||
return runtime.GOOS
|
||||
}
|
||||
}
|
||||
27
internal/riskcontrol/osmodel_darwin.go
Normal file
27
internal/riskcontrol/osmodel_darwin.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// readDeviceModel reads the current product key first and falls back to the
|
||||
// legacy model key. Trying both keys is more robust than branching on a macOS
|
||||
// version because virtualized or restricted environments may expose only one.
|
||||
func readDeviceModel() string {
|
||||
return readDarwinDeviceModel(unix.Sysctl)
|
||||
}
|
||||
|
||||
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
|
||||
for _, key := range [...]string{"hw.product", "hw.model"} {
|
||||
model, err := readSysctl(key)
|
||||
if err == nil {
|
||||
if model = normalizeDeviceModel(model); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
|
||||
t.Run("product available", func(t *testing.T) {
|
||||
var keys []string
|
||||
got := readDarwinDeviceModel(func(key string) (string, error) {
|
||||
keys = append(keys, key)
|
||||
if key == "hw.product" {
|
||||
return "Mac16,1", nil
|
||||
}
|
||||
return "", errors.New("unexpected fallback")
|
||||
})
|
||||
if got != "Mac16,1" {
|
||||
t.Fatalf("model = %q, want %q", got, "Mac16,1")
|
||||
}
|
||||
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("sysctl keys = %v, want %v", keys, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("product unavailable", func(t *testing.T) {
|
||||
var keys []string
|
||||
got := readDarwinDeviceModel(func(key string) (string, error) {
|
||||
keys = append(keys, key)
|
||||
if key == "hw.model" {
|
||||
return "MacBookPro18,3", nil
|
||||
}
|
||||
return "", errors.New("not available")
|
||||
})
|
||||
if got != "MacBookPro18,3" {
|
||||
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
|
||||
}
|
||||
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("sysctl keys = %v, want %v", keys, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
17
internal/riskcontrol/osmodel_linux.go
Normal file
17
internal/riskcontrol/osmodel_linux.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
|
||||
// values vary widely and can expose the host or virtualization platform when
|
||||
// the CLI runs in a container or sandbox.
|
||||
func readDeviceModel() string {
|
||||
return readLinuxDeviceModel()
|
||||
}
|
||||
|
||||
func readLinuxDeviceModel() string {
|
||||
return "linux"
|
||||
}
|
||||
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReadDeviceModelReturnsLinux(t *testing.T) {
|
||||
if got := readDeviceModel(); got != "linux" {
|
||||
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLinuxDeviceModel(t *testing.T) {
|
||||
if got := readLinuxDeviceModel(); got != "linux" {
|
||||
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
|
||||
}
|
||||
}
|
||||
11
internal/riskcontrol/osmodel_other.go
Normal file
11
internal/riskcontrol/osmodel_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
// readDeviceModel returns an empty model on unsupported platforms.
|
||||
func readDeviceModel() string {
|
||||
return ""
|
||||
}
|
||||
143
internal/riskcontrol/osmodel_test.go
Normal file
143
internal/riskcontrol/osmodel_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
|
||||
calls := 0
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls++
|
||||
return " MacBookPro18,3\n"
|
||||
}}
|
||||
|
||||
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
|
||||
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
|
||||
}
|
||||
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
|
||||
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("read called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSourceCachesEmptyModel(t *testing.T) {
|
||||
calls := 0
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls++
|
||||
return ""
|
||||
}}
|
||||
|
||||
if got := s.Snapshot(); got.ProductModel != "" {
|
||||
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
|
||||
}
|
||||
if got := s.Snapshot(); got.ProductModel != "" {
|
||||
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("read called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls.Add(1)
|
||||
return "ThinkPad X1 Carbon"
|
||||
}}
|
||||
|
||||
const goroutines = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
snapshot := s.Snapshot()
|
||||
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
|
||||
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("read called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeviceModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
|
||||
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
|
||||
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
|
||||
{name: "rejects empty", model: " \t\r\n"},
|
||||
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
|
||||
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
|
||||
{name: "normalizes tab", model: "model\tname", want: "model name"},
|
||||
{name: "removes NUL", model: "model\x00name", want: "modelname"},
|
||||
{name: "removes control character", model: "model\x1fname", want: "modelname"},
|
||||
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
|
||||
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
|
||||
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
|
||||
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
|
||||
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizeDeviceModel(tt.model); got != tt.want {
|
||||
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
|
||||
for value := 0; value <= 0x7f; value++ {
|
||||
if value >= 0x20 && value < 0x7f {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
|
||||
model := "model" + string(rune(value)) + "name"
|
||||
want := "modelname"
|
||||
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
|
||||
want = "model name"
|
||||
}
|
||||
if got := normalizeDeviceModel(model); got != want {
|
||||
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOSType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want OSType
|
||||
}{
|
||||
{name: "Windows", want: OSTypeWindows},
|
||||
{name: "Linux", want: OSTypeLinux},
|
||||
{name: "MacOS", want: OSTypeMacOS},
|
||||
{name: "unknown", want: OSTypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := GetOSType(tt.name); got != tt.want {
|
||||
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
44
internal/riskcontrol/osmodel_windows.go
Normal file
44
internal/riskcontrol/osmodel_windows.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "golang.org/x/sys/windows/registry"
|
||||
|
||||
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
|
||||
var systemInfoRegistryPaths = [...]string{
|
||||
`HARDWARE\DESCRIPTION\System\BIOS`,
|
||||
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
|
||||
`SYSTEM\HardwareConfig\Current`,
|
||||
}
|
||||
|
||||
// readDeviceModel returns the first product name found in the Windows registry.
|
||||
func readDeviceModel() string {
|
||||
return readWindowsDeviceModel(readWindowsRegistryModel)
|
||||
}
|
||||
|
||||
func readWindowsRegistryModel(path string) (string, error) {
|
||||
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
model, _, err := key.GetStringValue("SystemProductName")
|
||||
return model, err
|
||||
}
|
||||
|
||||
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
|
||||
for _, path := range systemInfoRegistryPaths {
|
||||
model, err := readRegistryModel(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if model = normalizeDeviceModel(model); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWindowsDeviceModelFallback(t *testing.T) {
|
||||
readError := errors.New("registry read failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
errors map[string]error
|
||||
want string
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
name: "first path wins",
|
||||
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
|
||||
want: "Surface Laptop",
|
||||
wantPaths: []string{systemInfoRegistryPaths[0]},
|
||||
},
|
||||
{
|
||||
name: "read failure falls back",
|
||||
errors: map[string]error{
|
||||
systemInfoRegistryPaths[0]: readError,
|
||||
},
|
||||
values: map[string]string{
|
||||
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
|
||||
},
|
||||
want: "ThinkPad X1 Carbon",
|
||||
wantPaths: systemInfoRegistryPaths[:2],
|
||||
},
|
||||
{
|
||||
name: "empty normalized value falls back",
|
||||
values: map[string]string{
|
||||
systemInfoRegistryPaths[0]: " \r\n\x00",
|
||||
systemInfoRegistryPaths[1]: "Latitude 7450",
|
||||
},
|
||||
want: "Latitude 7450",
|
||||
wantPaths: systemInfoRegistryPaths[:2],
|
||||
},
|
||||
{
|
||||
name: "all paths fail",
|
||||
errors: map[string]error{
|
||||
systemInfoRegistryPaths[0]: readError,
|
||||
systemInfoRegistryPaths[1]: readError,
|
||||
systemInfoRegistryPaths[2]: readError,
|
||||
},
|
||||
wantPaths: systemInfoRegistryPaths[:],
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var paths []string
|
||||
got := readWindowsDeviceModel(func(path string) (string, error) {
|
||||
paths = append(paths, path)
|
||||
if err := tt.errors[path]; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tt.values[path], nil
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("model = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !reflect.DeepEqual(paths, tt.wantPaths) {
|
||||
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
138
internal/riskcontrol/transport.go
Normal file
138
internal/riskcontrol/transport.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
)
|
||||
|
||||
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
|
||||
|
||||
// Transport is the feature's final outbound boundary. It removes caller- or
|
||||
// extension-supplied signal headers first and writes trusted values only after
|
||||
// authorizing an official SDK origin and authentication state.
|
||||
type Transport struct {
|
||||
next http.RoundTripper
|
||||
source Source
|
||||
}
|
||||
|
||||
// NewTransport creates the final SDK outbound policy boundary. A nil source
|
||||
// disables collection and injection while preserving restricted-header
|
||||
// stripping for opt-out and extension-credential requests.
|
||||
func NewTransport(next http.RoundTripper, source Source) *Transport {
|
||||
if next == nil {
|
||||
next = internaltransport.Fallback()
|
||||
}
|
||||
return &Transport{
|
||||
next: next,
|
||||
source: source,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Header == nil {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
stripRestrictedHeaders(req.Header)
|
||||
|
||||
if t.source != nil && t.routeAllowsSignals(req) {
|
||||
snapshot := t.source.Snapshot()
|
||||
if isSupportedOSType(snapshot.OSType) {
|
||||
req.Header.Set(HeaderOSType, string(snapshot.OSType))
|
||||
}
|
||||
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
|
||||
req.Header.Set(HeaderProductModel, model)
|
||||
}
|
||||
}
|
||||
return t.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
func isSupportedOSType(value OSType) bool {
|
||||
switch value {
|
||||
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stripRestrictedHeaders(header http.Header) {
|
||||
for name := range header {
|
||||
for _, restricted := range restrictedHeaders {
|
||||
if strings.EqualFold(name, restricted) {
|
||||
delete(header, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type origin struct {
|
||||
scheme string
|
||||
host string
|
||||
port string
|
||||
}
|
||||
|
||||
var officialFeishuOrigins = [...]origin{
|
||||
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
|
||||
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
|
||||
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
|
||||
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
|
||||
}
|
||||
|
||||
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
|
||||
if req == nil || req.URL == nil {
|
||||
return false
|
||||
}
|
||||
return isOfficialFeishuOrigin(originOf(req.URL))
|
||||
}
|
||||
|
||||
func originOf(value *url.URL) origin {
|
||||
if value == nil {
|
||||
return origin{}
|
||||
}
|
||||
scheme := strings.ToLower(value.Scheme)
|
||||
port := value.Port()
|
||||
if port == "" {
|
||||
switch scheme {
|
||||
case "https":
|
||||
port = "443"
|
||||
case "http":
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
|
||||
}
|
||||
|
||||
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
|
||||
endpoint, err := url.Parse(endpointURL)
|
||||
if err != nil {
|
||||
return origin{}
|
||||
}
|
||||
return originOf(endpoint)
|
||||
}
|
||||
|
||||
func isOfficialFeishuOrigin(candidate origin) bool {
|
||||
if candidate.scheme != "https" || candidate.port != "443" {
|
||||
return false
|
||||
}
|
||||
for _, official := range officialFeishuOrigins {
|
||||
if candidate == official {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
124
internal/riskcontrol/transport_test.go
Normal file
124
internal/riskcontrol/transport_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type countingSource struct {
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *countingSource) Snapshot() Snapshot {
|
||||
s.calls.Add(1)
|
||||
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
|
||||
}
|
||||
|
||||
type staticSource Snapshot
|
||||
|
||||
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
|
||||
|
||||
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestURL string
|
||||
authorization string
|
||||
wantSignals bool
|
||||
}{
|
||||
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
|
||||
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := &countingSource{}
|
||||
var received http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", test.authorization)
|
||||
req.Header.Set(HeaderOSType, "caller-value")
|
||||
req.Header.Set(HeaderProductModel, "caller-value")
|
||||
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
|
||||
|
||||
resp, err := NewTransport(base, source).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
gotSignals := received.Get(HeaderOSType) != ""
|
||||
if gotSignals != test.wantSignals {
|
||||
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
|
||||
}
|
||||
wantCalls := int32(0)
|
||||
if test.wantSignals {
|
||||
wantCalls = 1
|
||||
}
|
||||
if got := source.calls.Load(); got != wantCalls {
|
||||
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
|
||||
}
|
||||
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
|
||||
t.Fatalf("caller request OS header = %q, want unchanged", got)
|
||||
}
|
||||
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
|
||||
t.Fatalf("caller request product-model header = %q, want unchanged", got)
|
||||
}
|
||||
if !test.wantSignals {
|
||||
for name := range received {
|
||||
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
|
||||
t.Fatalf("restricted header leaked as %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportValidatesSourceSnapshot(t *testing.T) {
|
||||
var received http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := NewTransport(base, staticSource{
|
||||
OSType: OSType("unsupported"),
|
||||
ProductModel: "unsafe\nvalue",
|
||||
}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
|
||||
t.Fatalf("no signals collected: %v", received)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// LocalInputPath validates a local input path without restricting it to the
|
||||
// current working directory. It delegates to localfileio.LocalInputPath so
|
||||
// command validation and shared local-file readers use one policy.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
return localfileio.LocalInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
|
||||
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
|
||||
got, err := LocalInputPath(path)
|
||||
if err != nil || got != path {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalInputPath("report\n.pdf"); err == nil {
|
||||
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// LocalInputPath validates an input path in the process local filesystem
|
||||
// namespace. It intentionally does not impose cwd containment or canonicalize
|
||||
// the path: absolute paths, parent-relative paths, and symlink traversal retain
|
||||
// their normal OS semantics. Character validation remains mandatory because
|
||||
// paths are user-controlled and may appear in errors or progress output.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("local input path must not be empty")
|
||||
}
|
||||
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
|
||||
return "", fmt.Errorf("local input path must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalInputPlatform(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsNonLocalNamespace(path string) bool {
|
||||
normalized := strings.ReplaceAll(path, "/", `\`)
|
||||
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
8
internal/vfs/localfileio/path_local_other.go
Normal file
8
internal/vfs/localfileio/path_local_other.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package localfileio
|
||||
|
||||
func validateLocalInputPlatform(string) error { return nil }
|
||||
33
internal/vfs/localfileio/path_local_windows.go
Normal file
33
internal/vfs/localfileio/path_local_windows.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateLocalInputPlatform(path string) error {
|
||||
if isWindowsNonLocalNamespace(path) {
|
||||
return fmt.Errorf("local input path must not use a Windows network or device namespace")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(path)
|
||||
volume := filepath.VolumeName(cleaned)
|
||||
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
|
||||
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
|
||||
return r == '\\' || r == '/'
|
||||
}) {
|
||||
if component == "." || component == ".." {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsLocal(component) {
|
||||
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
`C:\Users\agent\NUL.txt`,
|
||||
`CON`,
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/tmp/report.pdf",
|
||||
"../outside/report.pdf",
|
||||
"./report.pdf",
|
||||
"nested/../report.pdf",
|
||||
`C:\Users\agent\report.pdf`,
|
||||
"报告.pdf",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
got, err := LocalInputPath(input)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
|
||||
}
|
||||
if got != input {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsNonLocalNamespace(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
} {
|
||||
if !isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{
|
||||
`C:\Users\agent\report.pdf`,
|
||||
`C:/Users/agent/report.pdf`,
|
||||
`..\outside\report.pdf`,
|
||||
`.\report.pdf`,
|
||||
} {
|
||||
if isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
" ",
|
||||
"file\x00.txt",
|
||||
"file\tname.txt",
|
||||
"file\nname.txt",
|
||||
"file\rname.txt",
|
||||
"file\u202Ename.txt",
|
||||
"file\u200Bname.txt",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.77",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.77",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.77",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -12,10 +12,23 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
|
||||
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
|
||||
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
|
||||
const maxFileListPageSize = 200
|
||||
|
||||
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
|
||||
func validateFileListPageSize(n int) error {
|
||||
if n < 1 || n > maxFileListPageSize {
|
||||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
|
||||
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
|
||||
@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
|
||||
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
|
||||
for _, ps := range []string{"0", "201", "500"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
|
||||
}
|
||||
if ve.Param != "--page-size" {
|
||||
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验(dry-run 不报错并把 page_size 下发)。
|
||||
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
|
||||
for _, ps := range []string{"1", "200"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
|
||||
// file and previews the pre-upload request without reading or uploading it.
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
absolutePath := filepath.Join(t.TempDir(), "logo.png")
|
||||
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
|
||||
// absolute path outside the current working directory.
|
||||
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-abs"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Keep the process cwd unchanged so the temporary file is outside it.
|
||||
dir := t.TempDir()
|
||||
absFile := filepath.Join(dir, "report.pdf")
|
||||
if !filepath.IsAbs(absFile) {
|
||||
t.Fatalf("test setup: %q is not absolute", absFile)
|
||||
}
|
||||
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with absolute path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PDFBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-parent"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with parent-relative path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PARENT" {
|
||||
t.Fatalf("PUT body = %q, want PARENT", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.bin")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err = runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "limit") {
|
||||
t.Fatalf("error = %v, want size limit context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("/dev/zero is unavailable on Windows")
|
||||
}
|
||||
if _, err := os.Stat("/dev/zero"); err != nil {
|
||||
t.Skipf("/dev/zero unavailable: %v", err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "regular file") {
|
||||
t.Fatalf("error = %v, want non-regular-file context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
|
||||
@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "write",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
|
||||
@@ -2056,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
|
||||
if s.Service != "base" {
|
||||
t.Fatalf("Service=%q want base", s.Service)
|
||||
}
|
||||
if s.Risk != "write" {
|
||||
t.Fatalf("Risk=%q want write", s.Risk)
|
||||
if s.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
|
||||
}
|
||||
if !s.HasFormat {
|
||||
t.Fatal("HasFormat should be true")
|
||||
@@ -2357,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"+form-submit",
|
||||
"--share-token", "shr_exec1",
|
||||
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2425,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_exec6",
|
||||
"--base-token", "bas_exec6",
|
||||
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
@@ -2473,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_dedup",
|
||||
"--base-token", "bas_dedup",
|
||||
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2484,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
|
||||
// without --yes the runner's confirmation gate must fire before Execute runs,
|
||||
// returning a typed confirmation_required error and touching no API.
|
||||
func TestFormSubmitRequiresConfirmation(t *testing.T) {
|
||||
if BaseFormSubmit.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{
|
||||
"+form-submit",
|
||||
"--share-token", "shr_confirm",
|
||||
"--json", `{"fields":{"Rating":5}}`,
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required error without --yes")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
t.Run("single file upload via execute path", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
@@ -2520,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_para1",
|
||||
"--base-token", "bas_para1",
|
||||
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2554,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_err",
|
||||
"--base-token", "bas_err",
|
||||
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
|
||||
146
shortcuts/common/localfile.go
Normal file
146
shortcuts/common/localfile.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ValidateLocalFileFlag validates that a local input path exists, is a regular
|
||||
// file, and does not exceed maxBytes. Absolute and relative paths use
|
||||
// the process filesystem namespace.
|
||||
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := cmdutil.StatLocalFile(path)
|
||||
if err != nil {
|
||||
return localFileReadError(param, path, "inspect", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return localFileSizeError(param, path, info.Size(), maxBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
|
||||
// shortcuts. It accepts absolute and relative paths, enforces a hard size
|
||||
// limit, and returns command-facing typed errors.
|
||||
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := cmdutil.OpenLocalFile(path)
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "open", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil && retErr == nil {
|
||||
data = nil
|
||||
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
|
||||
}
|
||||
}()
|
||||
|
||||
openedInfo, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "inspect opened", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if openedInfo.Size() > maxBytes {
|
||||
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
|
||||
}
|
||||
|
||||
readLimit := maxBytes + 1
|
||||
if maxBytes == math.MaxInt64 {
|
||||
readLimit = maxBytes
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(f, readLimit))
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "read", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
|
||||
WithParam(param)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
|
||||
name, param, err := localFileFlagNames(flagName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ctx == nil || ctx.Cmd == nil {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
|
||||
}
|
||||
|
||||
path = strings.TrimSpace(ctx.Str(name))
|
||||
if path == "" {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
|
||||
}
|
||||
if _, err := validate.LocalInputPath(path); err != nil {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
if maxBytes < 0 {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
|
||||
}
|
||||
return path, param, nil
|
||||
}
|
||||
|
||||
func localFileRegularError(param, path string, mode fs.FileMode) error {
|
||||
if mode.IsRegular() {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is not a regular file", param, path).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileReadError(param, path, op string, cause error) error {
|
||||
if errors.Is(cause, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
if errors.Is(cause, fs.ErrNotExist) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
|
||||
}
|
||||
|
||||
func localFileSizeError(param, path string, size, limit int64) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileFlagNames(flagName string) (name, param string, err error) {
|
||||
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
|
||||
if name == "" {
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
|
||||
}
|
||||
return name, "--" + name, nil
|
||||
}
|
||||
95
shortcuts/common/localfile_test.go
Normal file
95
shortcuts/common/localfile_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := localFileTestRuntime(t, path)
|
||||
|
||||
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
|
||||
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
|
||||
}
|
||||
got, err := rctx.ReadLocalFileFlag("file", 7)
|
||||
if err != nil || string(got) != "content" {
|
||||
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
if err := cmd.Flags().Set("file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
|
||||
}
|
||||
@@ -3,11 +3,24 @@
|
||||
|
||||
package slides
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var presentationFlagAliases = []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
all := []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
@@ -18,4 +31,39 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
for i := range all {
|
||||
if hasPresentationFlag(all[i].Flags) {
|
||||
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasPresentationFlag(flags []common.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "presentation" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withPresentationFlagAliases accepts common agent-generated spellings for
|
||||
// --presentation without registering extra flags. The aliases therefore stay
|
||||
// out of help and completion while resolving to the canonical flag at parse
|
||||
// time, matching the zero-round-trip compatibility used by Sheets.
|
||||
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
if name == alias {
|
||||
return pflag.NormalizedName("presentation")
|
||||
}
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
68
shortcuts/slides/shortcuts_alias_test.go
Normal file
68
shortcuts/slides/shortcuts_alias_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWithPresentationFlagAliases(t *testing.T) {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withPresentationFlagAliases(nil)(cmd)
|
||||
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Fatalf("read --presentation: %v", err)
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
|
||||
count := 0
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !hasPresentationFlag(shortcut.Flags) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if shortcut.PostMount == nil {
|
||||
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
shortcut.PostMount(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
|
||||
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one slides shortcut with --presentation")
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,13 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
@@ -57,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if strings.TrimSpace(runtime.Str("content")) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
@@ -73,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -148,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -200,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
@@ -219,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,23 +18,19 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
base := []string{"slides:presentation:screenshot"}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
@@ -188,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1,slide_2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
|
||||
}
|
||||
|
||||
path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
|
||||
if _, err := os.ReadFile(path1); err != nil {
|
||||
t.Fatalf("read first CSV slide screenshot: %v", err)
|
||||
}
|
||||
path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
|
||||
if _, err := os.ReadFile(path2); err != nil {
|
||||
t.Fatalf("read second CSV slide screenshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
// CSV with a duplicate and blank segments should normalize the same way
|
||||
// normalizeSlideIDs already does for repeated --slide-id flags.
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1, slide_2,slide_1,",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %v, want typed validation error", err)
|
||||
}
|
||||
if problem.Hint != "request at most 10 pages at a time" {
|
||||
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
@@ -390,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
// Exercises the --slide-number-only side of the --content conflict check
|
||||
// (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
|
||||
// --slide-id side of that same `||` condition).
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--slide-number", "1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
|
||||
t.Fatalf("error = %v, want content/slide selector conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ import (
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content. When --output is
|
||||
// provided it writes reindented XML to a local file, and --raw prints
|
||||
// reindented XML to stdout; otherwise it returns the server's original
|
||||
// content unmodified in the standard JSON envelope. Use --slide-id or
|
||||
// --slide-number to fetch one page.
|
||||
// provided it writes to a local file; otherwise it returns the XML in the
|
||||
// standard JSON envelope. Use --slide-id or --slide-number to fetch one page,
|
||||
// and use --raw for direct XML stdout.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
@@ -31,8 +30,8 @@ var SlidesXMLGet = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; the saved file is formatted for readability; must be a relative path within the current directory; existing file is overwritten; omit to return the server's original XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print formatted XML to stdout without the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "output", Desc: "local XML output path; must be a relative path within the current directory; existing file is overwritten; omit to return XML in the JSON envelope"},
|
||||
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
|
||||
{Name: "slide-id", Desc: "slide page identifier; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "slide-number", Type: "int", Desc: "1-based slide page number; omit both slide selectors to fetch full presentation XML"},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
@@ -109,10 +108,10 @@ var SlidesXMLGet = common.Shortcut{
|
||||
}
|
||||
dry.GET(path).Params(params)
|
||||
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; formatted XML content is saved to --output during execution")
|
||||
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
}
|
||||
if runtime.Bool("raw") {
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "formatted XML content is printed to stdout during execution")
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
|
||||
}
|
||||
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with XML content is printed to stdout during execution")
|
||||
},
|
||||
@@ -251,31 +250,22 @@ func fetchSlidesXMLGetContent(runtime *common.RuntimeContext, presentationID str
|
||||
return content, out, nil
|
||||
}
|
||||
|
||||
// outputSlidesXMLGetContent routes the fetched XML to its output surface.
|
||||
// Only the text surfaces are reindented: --raw stdout and --output files are
|
||||
// read directly by humans and line tools. The JSON envelope carries the
|
||||
// server content verbatim instead -- inside a JSON string every newline is
|
||||
// escaped to \n, so formatting there buys no readability and only inflates
|
||||
// the payload, while passthrough keeps that read path byte-exact without
|
||||
// even parsing the content.
|
||||
func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, outputPath string, out map[string]interface{}) error {
|
||||
if outputPath == "" {
|
||||
if !runtime.Bool("raw") {
|
||||
runtime.OutFormatRaw(out, nil, nil)
|
||||
return nil
|
||||
}
|
||||
formatted, _ := prettyPrintXMLOrOriginal(runtime, content)
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, formatted); err != nil {
|
||||
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
formatted, prettyPrinted := prettyPrintXMLOrOriginal(runtime, content)
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(formatted)),
|
||||
}, bytes.NewReader([]byte(formatted)))
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
@@ -290,7 +280,6 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
"pretty_printed": prettyPrinted,
|
||||
}
|
||||
for _, key := range []string{"revision_id", "remove_attr_id", "slide_id", "slide_number"} {
|
||||
if value, ok := out[key]; ok {
|
||||
@@ -300,17 +289,3 @@ func outputSlidesXMLGetContent(runtime *common.RuntimeContext, content string, o
|
||||
runtime.Out(fileOut, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prettyPrintXMLOrOriginal keeps xml-get best-effort: if the server returns
|
||||
// content that is not strictly valid XML, callers still receive the original
|
||||
// content and a warning on stderr instead of losing the read path. The bool
|
||||
// reports whether pretty-printing succeeded, surfaced as pretty_printed in
|
||||
// --output file metadata.
|
||||
func prettyPrintXMLOrOriginal(runtime *common.RuntimeContext, xmlContent string) (string, bool) {
|
||||
out, err := prettyPrintXML(xmlContent)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: XML pretty-print skipped; returning original server content: %v\n", err)
|
||||
return xmlContent, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML (not derived by
|
||||
// calling it): a bug in prettyPrintXML itself must not be able to make
|
||||
// this assertion pass by construction.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -64,10 +60,10 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), wantXML) {
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
@@ -84,11 +80,8 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["pretty_printed"] != true {
|
||||
t.Fatalf("pretty_printed = %v, want true", data["pretty_printed"])
|
||||
}
|
||||
if data["size"] != float64(len(wantXML)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(wantXML))
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
@@ -103,12 +96,7 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// The JSON envelope carries the server content verbatim: no reindentation
|
||||
// and no parse/reserialize cycle. Reintroducing the in-repo formatter
|
||||
// would fail this by inserting indentation; the   reference
|
||||
// additionally guards against a naive parse-and-reserialize round trip,
|
||||
// which would decode it to a literal space.
|
||||
xml := `<presentation><slide id="s1"><shape id="a"><content><p><span>Hello</span> <strong>World</strong></p></content></shape></slide></presentation>`
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -134,14 +122,11 @@ func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation := data["xml_presentation"].(map[string]interface{})
|
||||
if got := presentation["content"]; got != xml {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, xml)
|
||||
t.Fatalf("content = %q, want %q", got, xml)
|
||||
}
|
||||
if got := data["xml_presentation_id"]; got != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "content_saved") {
|
||||
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
|
||||
}
|
||||
@@ -151,8 +136,6 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// --jq extracts fields from the envelope, and the envelope carries the
|
||||
// server content verbatim, so the filter yields the single-line original.
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -178,18 +161,15 @@ func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != xml {
|
||||
t.Fatalf("stdout = %q, want the server content verbatim %q", got, xml)
|
||||
t.Fatalf("stdout = %q, want XML content %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T) {
|
||||
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
@@ -213,32 +193,16 @@ func TestSlidesXMLGetPrintsFormattedContentWithoutEnvelopeWhenRaw(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != wantXML {
|
||||
t.Fatalf("stdout = %q, want formatted XML %q", got, wantXML)
|
||||
if got := stdout.String(); got != xml {
|
||||
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRawFlagDocumentsFormattedOutput(t *testing.T) {
|
||||
for _, flag := range SlidesXMLGet.Flags {
|
||||
if flag.Name != "raw" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(flag.Desc, "formatted XML") || strings.Contains(flag.Desc, "raw XML") {
|
||||
t.Fatalf("--raw description = %q, want formatted XML without a raw-payload claim", flag.Desc)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("--raw flag not found")
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
// Golden value computed independently of prettyPrintXML; see the comment
|
||||
// in TestSlidesXMLGetWritesContentToFileAndSuppressesXML.
|
||||
wantXML := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -280,8 +244,8 @@ func TestSlidesXMLGetFetchesSingleSlideByIDToFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read saved slide XML: %v", err)
|
||||
}
|
||||
if string(got) != wantXML {
|
||||
t.Fatalf("saved XML = %q, want %q", got, wantXML)
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["scope"] != "slide" {
|
||||
@@ -299,8 +263,6 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
// The slide envelope carries the server content verbatim, like the
|
||||
// presentation envelope.
|
||||
xml := `<slide id="slide_2"><data><shape id="b"/></data></slide>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -343,14 +305,11 @@ func TestSlidesXMLGetFetchesSingleSlideByNumberEnvelope(t *testing.T) {
|
||||
}
|
||||
slide := data["slide"].(map[string]interface{})
|
||||
if slide["content"] != xml {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", slide["content"], xml)
|
||||
t.Fatalf("content = %q, want %q", slide["content"], xml)
|
||||
}
|
||||
if slide["slide_id"] != "slide_2" {
|
||||
t.Fatalf("slide.slide_id = %v, want slide_2", slide["slide_id"])
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
@@ -556,341 +515,3 @@ func TestSlidesXMLGetRejectsRemoveAttrIDForSingleSlide(t *testing.T) {
|
||||
t.Fatalf("param = %q, want --remove-attr-id", validationErr.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXML(t *testing.T) {
|
||||
input := `<presentation id="p1" xmlns="http://www.larkoffice.com/sml/2.0" width="960"><slide id="s1"><style><fill id="f1"><fillColor color="rgba(0,0,0,1)"/></fill></style><data/></slide></presentation>`
|
||||
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "\n") {
|
||||
t.Fatalf("expected reindented output with newlines, got %q", got)
|
||||
}
|
||||
if n := strings.Count(got, `xmlns="http://www.larkoffice.com/sml/2.0"`); n != 1 {
|
||||
t.Fatalf("expected the xmlns declaration to appear exactly once, got %d occurrences in %q", n, got)
|
||||
}
|
||||
if !strings.Contains(got, "<data/>") {
|
||||
t.Fatalf("expected empty <data/> to stay self-closing, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `<fillColor color="rgba(0,0,0,1)"/>`) {
|
||||
t.Fatalf("expected attributes to be preserved on their element, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLRejectsMalformedInput(t *testing.T) {
|
||||
if _, err := prettyPrintXML(`<presentation><slide></presentation>`); err == nil {
|
||||
t.Fatal("expected an error for malformed XML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedWhitespaceReferences covers the schema's
|
||||
// documented space/tab escape idiom (slides_xml_schema_definition.xml, <p>
|
||||
// element docs) and CR/LF references whose lexical form is needed to avoid
|
||||
// XML line-ending normalization on a later parse. An XML parser decodes the
|
||||
// references into literal whitespace. The formatter must preserve their
|
||||
// lexical representation for safe read-modify-write workflows.
|
||||
func TestPrettyPrintXMLPreservesEscapedWhitespaceReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"space in p", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"tab in p", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"space in nested span", `<content><p><span> </span></p></content>`, "<content>\n <p><span> </span></p>\n</content>\n"},
|
||||
{"hex space", `<content><p> </p></content>`, "<content>\n <p> </p>\n</content>\n"},
|
||||
{"zero-padded tab", `<content><p>	</p></content>`, "<content>\n <p>	</p>\n</content>\n"},
|
||||
{"carriage return", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"line feed", `<content><p>A B</p></content>`, "<content>\n <p>A B</p>\n</content>\n"},
|
||||
{"hex carriage return", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
{"hex line feed", `<content><p>A
B</p></content>`, "<content>\n <p>A
B</p>\n</content>\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesTextOnlyLeafWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "title literal space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title escaped space",
|
||||
input: `<presentation><title> </title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> </title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "title whitespace CDATA",
|
||||
input: `<presentation><title><![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title><![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "chart field literal space",
|
||||
input: `<chartData><chartField name="x"> </chartField></chartData>`,
|
||||
want: "<chartData>\n <chartField name=\"x\"> </chartField>\n</chartData>\n",
|
||||
},
|
||||
{
|
||||
name: "title adjacent text and CDATA",
|
||||
input: `<presentation><title> <![CDATA[ ]]></title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title> <![CDATA[ ]]></title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings is the
|
||||
// critical case:   sitting as a bare sibling text node directly between
|
||||
// two inline elements, not wrapped in its own tag -- the literal reading of
|
||||
// the schema's "标签之间...请使用 " guidance, e.g. a plain-styled space
|
||||
// between two differently formatted words at a pptx run boundary. A fix
|
||||
// that only special-cases "element whose sole content is whitespace" does
|
||||
// not cover this: the whitespace here is one of several children of <p>,
|
||||
// not the sole child of <span>.
|
||||
func TestPrettyPrintXMLPreservesEscapedSpaceBetweenInlineSiblings(t *testing.T) {
|
||||
input := `<content><p><span>Hello</span> <strong>World</strong></p></content>`
|
||||
want := "<content>\n <p><span>Hello</span> <strong>World</strong></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLPreservesCDATA(t *testing.T) {
|
||||
input := `<content><p><![CDATA[a-->b & <c>]]></p></content>`
|
||||
want := "<content>\n <p><![CDATA[a-->b & <c>]]></p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText is the
|
||||
// feature's actual point: a shape with many paragraphs becomes navigable
|
||||
// (each <p> on its own indented line), while every paragraph's own rich
|
||||
// text -- including an inline formatting boundary -- stays byte-for-byte
|
||||
// unchanged.
|
||||
func TestPrettyPrintXMLSeparatesParagraphsWithoutTouchingTheirText(t *testing.T) {
|
||||
input := `<content><p>First paragraph.</p><p>Second <strong>paragraph</strong>.</p></content>`
|
||||
want := "<content>\n <p>First paragraph.</p>\n <p>Second <strong>paragraph</strong>.</p>\n</content>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLIdempotent(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a"><content><p>A  B	C D E</p></content><style/></shape></slide></presentation>`
|
||||
once, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (first pass): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(once)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML (second pass): %v", err)
|
||||
}
|
||||
if once != twice {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", once, twice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFallsBackToOriginalPresentationWhenReformatFails(t *testing.T) {
|
||||
content := "<presentation><title>\x0b</title><slide/></presentation>"
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--raw",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != content {
|
||||
t.Fatalf("stdout = %q, want original content %q", got, content)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent pins the
|
||||
// envelope contract: the content is never parsed, so even malformed XML
|
||||
// flows through byte for byte with no fallback warning and no
|
||||
// pretty_printed field.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedSlideContent(t *testing.T) {
|
||||
content := `<slide><data></slide>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide": map[string]interface{}{
|
||||
"slide_id": "slide_1",
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
slide, _ := data["slide"].(map[string]interface{})
|
||||
if slide == nil {
|
||||
t.Fatalf("missing slide: %#v", data)
|
||||
}
|
||||
if got, _ := slide["content"].(string); got != content {
|
||||
t.Fatalf("slide.content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent mirrors
|
||||
// the slide-scope passthrough test for the presentation-scope fetch branch,
|
||||
// which is a separate code path.
|
||||
func TestSlidesXMLGetEnvelopePassesThroughMalformedPresentationContent(t *testing.T) {
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
presentation, _ := data["xml_presentation"].(map[string]interface{})
|
||||
if presentation == nil {
|
||||
t.Fatalf("missing xml_presentation: %#v", data)
|
||||
}
|
||||
if got, _ := presentation["content"].(string); got != content {
|
||||
t.Fatalf("content = %q, want the server content verbatim %q", got, content)
|
||||
}
|
||||
if _, ok := data["pretty_printed"]; ok {
|
||||
t.Fatalf("pretty_printed should not appear in the envelope: %#v", data)
|
||||
}
|
||||
if got := stderr.String(); got != "" {
|
||||
t.Fatalf("stderr = %q, want empty: the envelope path must not parse the content", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetFileMetadataReportsPrettyPrintFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<presentation><slide></presentation>`
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "fallback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "fallback.xml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fallback XML: %v", err)
|
||||
}
|
||||
if string(got) != content {
|
||||
t.Fatalf("saved XML = %q, want original content %q", got, content)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["pretty_printed"] != false {
|
||||
t.Fatalf("pretty_printed = %v, want false", data["pretty_printed"])
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "warning: XML pretty-print skipped; returning original server content:") {
|
||||
t.Fatalf("stderr = %q, want explicit pretty-print fallback warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// textBearingTags are the SML elements whose schema content model is
|
||||
// mixed (arbitrary text interleaved with inline markup): the <p> paragraph
|
||||
// container and its inline formatting children, plus chart title/subtitle.
|
||||
// See slides_xml_schema_definition.xml, <p> element docs: a deliberate space
|
||||
// or tab is represented via  /	 character references. Reindentation
|
||||
// never descends into these elements; their entire subtree is copied
|
||||
// verbatim from the input, so those references keep their exact spelling.
|
||||
var textBearingTags = map[string]bool{
|
||||
"p": true,
|
||||
"strong": true,
|
||||
"em": true,
|
||||
"u": true,
|
||||
"span": true,
|
||||
"del": true,
|
||||
"a": true,
|
||||
"shadow": true,
|
||||
"outline": true,
|
||||
"chartTitle": true,
|
||||
"chartSubTitle": true,
|
||||
}
|
||||
|
||||
// tokenKind classifies a raw XML token for reindentation purposes.
|
||||
type tokenKind uint8
|
||||
|
||||
const (
|
||||
tokenStartElement tokenKind = iota // <name ...> or <name .../>
|
||||
tokenEndElement // </name>, or zero-width after <name .../>
|
||||
tokenCharData // text, character/entity references, or one CDATA section
|
||||
tokenOther // comment, processing instruction, or directive
|
||||
)
|
||||
|
||||
// rawToken records where one XML token lives inside the original input:
|
||||
// input[start:end] is the token's exact source bytes. The decoded token
|
||||
// value is deliberately discarded (only the element's local name is kept),
|
||||
// which is the core invariant of this formatter: output can only ever be
|
||||
// assembled from verbatim slices of the input, never from re-encoded data.
|
||||
type rawToken struct {
|
||||
kind tokenKind
|
||||
start int // byte offset of the token's first source byte
|
||||
end int // byte offset one past the token's last source byte
|
||||
local string // local element name (namespace prefix stripped); start elements only
|
||||
match int // start element: index of its matching end token; -1 otherwise
|
||||
}
|
||||
|
||||
// tokenize runs encoding/xml over the whole input purely as a tokenizer and
|
||||
// returns every token annotated with its raw byte range. Ranges come from
|
||||
// Decoder.InputOffset, which counts bytes (multi-byte UTF-8 content cannot
|
||||
// skew them), and consecutive tokens tile the input exactly, so slicing
|
||||
// between them loses nothing.
|
||||
//
|
||||
// The full document is decoded before anything is emitted: any syntax error
|
||||
// (mismatched or unclosed tags, invalid characters such as \x0b, undefined
|
||||
// entities, bare ]]> in text, ...) fails the whole pretty-print, keeping the
|
||||
// strict-parse behavior the fallback path in prettyPrintXMLOrOriginal
|
||||
// depends on.
|
||||
func tokenize(input string) ([]rawToken, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(input))
|
||||
var tokens []rawToken
|
||||
var openElements []int // indices into tokens of currently open start elements
|
||||
pos := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := int(decoder.InputOffset())
|
||||
raw := rawToken{start: pos, end: end, match: -1}
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
raw.kind = tokenStartElement
|
||||
raw.local = t.Name.Local
|
||||
openElements = append(openElements, len(tokens))
|
||||
case xml.EndElement:
|
||||
// A strict decoder never emits an end element without its start
|
||||
// element; guard anyway so a decoder change cannot panic here.
|
||||
if len(openElements) == 0 {
|
||||
return nil, errors.New("xml: unexpected end element")
|
||||
}
|
||||
raw.kind = tokenEndElement
|
||||
startIndex := openElements[len(openElements)-1]
|
||||
openElements = openElements[:len(openElements)-1]
|
||||
tokens[startIndex].match = len(tokens)
|
||||
case xml.CharData:
|
||||
raw.kind = tokenCharData
|
||||
default: // xml.Comment, xml.ProcInst, xml.Directive
|
||||
raw.kind = tokenOther
|
||||
}
|
||||
tokens = append(tokens, raw)
|
||||
pos = end
|
||||
}
|
||||
// A strict decoder reports unclosed elements as a syntax error before
|
||||
// returning io.EOF; guard anyway so truncated output is impossible.
|
||||
if len(openElements) != 0 {
|
||||
return nil, errors.New("xml: unexpected EOF: unclosed element")
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// prettyPrintXML reindents xmlContent so structural elements (presentation,
|
||||
// slide, shape, style, ...) each sit on their own line. The server returns
|
||||
// XML as a single unbroken line, and this is what makes the --raw and
|
||||
// --output text surfaces readable; the JSON envelope path never calls it
|
||||
// (see outputSlidesXMLGetContent).
|
||||
//
|
||||
// Offset-slicing invariant: encoding/xml serves purely as a tokenizer, and
|
||||
// every byte of the output is either a verbatim slice of the input or an
|
||||
// inserted "\n"+indent run between the children of a structural element.
|
||||
// Nothing is parsed-and-reserialized, so CDATA sections, whitespace
|
||||
// character references in any spelling ( ,  , 	, ,
|
||||
// , ...), entity lexical forms, attribute quoting, and in-tag
|
||||
// whitespace all survive byte-for-byte.
|
||||
//
|
||||
// Reindentation never enters a textBearingTags element and never touches a
|
||||
// leaf element (one with no element children), so document text — including
|
||||
// whitespace-only leaves such as <title> </title> — is never altered.
|
||||
func prettyPrintXML(xmlContent string) (string, error) {
|
||||
tokens, err := tokenize(xmlContent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The decoder tolerates element-free input (plain text, a lone comment,
|
||||
// nothing at all). A document without a root element is not XML the
|
||||
// formatter should claim success on; erroring routes it to the
|
||||
// original-content fallback instead of reporting pretty_printed: true.
|
||||
if !slices.ContainsFunc(tokens, func(t rawToken) bool { return t.kind == tokenStartElement }) {
|
||||
return "", errors.New("xml: no root element")
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(xmlContent) + len(xmlContent)/8)
|
||||
reindented := false
|
||||
for i := 0; i < len(tokens); {
|
||||
token := tokens[i]
|
||||
if token.kind == tokenStartElement {
|
||||
if reindented {
|
||||
// Any top-level element after the first is copied verbatim;
|
||||
// well-formed XML has a single root, so this arm only runs
|
||||
// on technically invalid multi-root input the decoder
|
||||
// happens to tolerate.
|
||||
out.WriteString(xmlContent[token.start:tokens[token.match].end])
|
||||
} else {
|
||||
writeElement(&out, xmlContent, tokens, i, 0)
|
||||
reindented = true
|
||||
}
|
||||
i = token.match + 1
|
||||
continue
|
||||
}
|
||||
// Document-level prolog and epilog (XML declaration, DOCTYPE,
|
||||
// comments, whitespace) pass through verbatim.
|
||||
out.WriteString(xmlContent[token.start:token.end])
|
||||
i++
|
||||
}
|
||||
formatted := out.String()
|
||||
if !strings.HasSuffix(formatted, "\n") {
|
||||
formatted += "\n"
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
// writeElement emits the element whose start token is tokens[startIndex],
|
||||
// indented as if at the given depth (two spaces per level).
|
||||
//
|
||||
// Text-bearing elements and leaf elements (no element children) are emitted
|
||||
// as a single verbatim input slice from open tag through close tag; for a
|
||||
// self-closing tag the synthesized end token is zero-width and the slice is
|
||||
// exactly the open tag. Structural elements (at least one element child,
|
||||
// not text-bearing) are reindented: text children that are pure literal
|
||||
// whitespace are dropped as pre-existing formatting, "\n"+indent is
|
||||
// inserted before every element, comment, and processing-instruction child,
|
||||
// kept text children stay glued in place with no indentation around them,
|
||||
// and the close tag moves to its own line unless the last kept child is
|
||||
// text.
|
||||
//
|
||||
// The whitespace-only test runs on the child's RAW source bytes: a
|
||||
// character reference ( ) or a CDATA section is not literal whitespace
|
||||
// there, so it is kept and its lexical form survives.
|
||||
func writeElement(out *strings.Builder, input string, tokens []rawToken, startIndex, depth int) {
|
||||
start := tokens[startIndex]
|
||||
end := tokens[start.match]
|
||||
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
|
||||
out.WriteString(input[start.start:end.end])
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteString(input[start.start:start.end])
|
||||
childIndent := "\n" + strings.Repeat(" ", depth+1)
|
||||
lastKeptIsText := false
|
||||
for i := startIndex + 1; i < start.match; {
|
||||
child := tokens[i]
|
||||
switch child.kind {
|
||||
case tokenCharData:
|
||||
if !isAllWhitespace(input[child.start:child.end]) {
|
||||
out.WriteString(input[child.start:child.end])
|
||||
lastKeptIsText = true
|
||||
}
|
||||
i++
|
||||
case tokenStartElement:
|
||||
out.WriteString(childIndent)
|
||||
writeElement(out, input, tokens, i, depth+1)
|
||||
lastKeptIsText = false
|
||||
i = child.match + 1
|
||||
default: // comment, processing instruction, directive
|
||||
out.WriteString(childIndent)
|
||||
out.WriteString(input[child.start:child.end])
|
||||
lastKeptIsText = false
|
||||
i++
|
||||
}
|
||||
}
|
||||
if !lastKeptIsText {
|
||||
out.WriteString("\n")
|
||||
out.WriteString(strings.Repeat(" ", depth))
|
||||
}
|
||||
out.WriteString(input[end.start:end.end])
|
||||
}
|
||||
|
||||
// hasElementChild reports whether the element starting at tokens[startIndex]
|
||||
// has at least one direct element child. The first start-element token that
|
||||
// appears before the matching end token is necessarily a direct child, so a
|
||||
// linear scan without depth tracking suffices.
|
||||
func hasElementChild(tokens []rawToken, startIndex int) bool {
|
||||
for i := startIndex + 1; i < tokens[startIndex].match; i++ {
|
||||
if tokens[i].kind == tokenStartElement {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllWhitespace reports whether s is non-empty and consists only of
|
||||
// literal XML whitespace bytes (space, tab, CR, LF). It is applied to raw
|
||||
// source bytes, where character references and CDATA markers count as
|
||||
// non-whitespace by construction.
|
||||
func isAllWhitespace(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The pure-function contract tests for prettyPrintXML (golden strings,
|
||||
// whitespace character references, leaf whitespace, CDATA, idempotency,
|
||||
// malformed rejection) live in slides_xml_get_test.go, unchanged from the
|
||||
// original etree-based implementation. This file adds engine-level cases
|
||||
// specific to the offset-slicing implementation.
|
||||
|
||||
func TestPrettyPrintXMLGoldenPresentation(t *testing.T) {
|
||||
input := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
want := "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyPrintXMLGoldenSlide(t *testing.T) {
|
||||
input := `<slide id="slide_1"><data><shape id="a"/></data></slide>`
|
||||
want := "<slide id=\"slide_1\">\n <data>\n <shape id=\"a\"/>\n </data>\n</slide>\n"
|
||||
got, err := prettyPrintXML(input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLRejectsMalformedInputTable pins that the whole document
|
||||
// is decoded before anything is emitted: even a late syntax error yields no
|
||||
// partial output, only the error the fallback path reports.
|
||||
func TestPrettyPrintXMLRejectsMalformedInputTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"mismatched close tag", `<presentation><slide></presentation>`},
|
||||
{"unclosed slide from fallback test", `<slide><data></slide>`},
|
||||
{"invalid control character", "<presentation><title>\x0b</title><slide/></presentation>"},
|
||||
{"unclosed root", `<presentation><slide/>`},
|
||||
{"undefined entity", `<presentation><title> </title></presentation>`},
|
||||
{"bare close tag", `</presentation>`},
|
||||
{"unescaped cdata terminator in text", `<presentation><title>a]]>b</title></presentation>`},
|
||||
{"late error after valid prefix", `<presentation><slide/><slide/><slide id=></presentation>`},
|
||||
{"empty input", ``},
|
||||
{"whitespace-only input", ` `},
|
||||
{"plain text without markup", `hello`},
|
||||
{"comment-only document", `<!-- only a comment -->`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want error", tt.input, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("prettyPrintXML(%q) returned partial output %q alongside error %v", tt.input, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText pins that user content
|
||||
// resembling the previous implementation's masking placeholders
|
||||
// (LARKCLI_XML_WHITESPACE_REFERENCE_<n>_) flows through untouched now that
|
||||
// no masking exists at all.
|
||||
func TestPrettyPrintXMLIgnoresMaskingEraPlaceholderText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "placeholder-shaped text in p",
|
||||
input: `<content><p>LARKCLI_XML_WHITESPACE_REFERENCE_0_ end</p></content>`,
|
||||
want: "<content>\n <p>LARKCLI_XML_WHITESPACE_REFERENCE_0_ end</p>\n</content>\n",
|
||||
},
|
||||
{
|
||||
name: "placeholder-shaped text in leaf",
|
||||
input: `<presentation><title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>LARKCLI_XML_WHITESPACE_REFERENCE_1_</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "placeholder-shaped attribute value",
|
||||
input: `<presentation><slide note="LARKCLI_XML_WHITESPACE_REFERENCE_0_"><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <slide note=\"LARKCLI_XML_WHITESPACE_REFERENCE_0_\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLStructuralTable covers comments, processing
|
||||
// instructions, prolog/DOCTYPE, mixed text between structural children,
|
||||
// CRLF pre-formatting, and multi-byte UTF-8 around offset boundaries.
|
||||
// Expected outputs were verified byte-identical against the previous
|
||||
// etree-based implementation via a differential probe.
|
||||
func TestPrettyPrintXMLStructuralTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
// wantSecond is the expected output of formatting the output again.
|
||||
// Usually equal to want (idempotent); the mixed-content rows pin the
|
||||
// one known non-idempotent shape, where kept text merges with the
|
||||
// inserted indent on reparse — byte-identical to the previous
|
||||
// implementation's behavior on the same inputs. Real SML structural
|
||||
// elements carry no mixed text, so the contract's idempotency
|
||||
// guarantee is unaffected.
|
||||
wantSecond string
|
||||
}{
|
||||
{
|
||||
name: "comment child is indented like an element",
|
||||
input: `<presentation><!-- deck notes --><slide/></presentation>`,
|
||||
want: "<presentation>\n <!-- deck notes -->\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "processing instruction child is indented like an element",
|
||||
input: `<presentation><?pi data?><slide/></presentation>`,
|
||||
want: "<presentation>\n <?pi data?>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "xml declaration prolog stays glued to the root",
|
||||
input: `<?xml version="1.0" encoding="UTF-8"?><presentation><slide/></presentation>`,
|
||||
want: "<?xml version=\"1.0\" encoding=\"UTF-8\"?><presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "prolog with doctype and trailing newline preserved verbatim",
|
||||
input: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation><slide/></presentation>\n",
|
||||
want: "<?xml version=\"1.0\"?>\n<!DOCTYPE presentation>\n<presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "document-level trailing comment preserved verbatim",
|
||||
input: "<presentation><slide/></presentation><!-- tail -->",
|
||||
want: "<presentation>\n <slide/>\n</presentation><!-- tail -->\n",
|
||||
},
|
||||
{
|
||||
name: "kept mixed text glues to previous sibling and close tag",
|
||||
input: `<data>x<child/>y</data>`,
|
||||
want: "<data>x\n <child/>y</data>\n",
|
||||
wantSecond: "<data>x\n \n <child/>y</data>\n",
|
||||
},
|
||||
{
|
||||
name: "kept mixed text does not suppress indent of next element",
|
||||
input: `<data>x<child/>y<child/></data>`,
|
||||
want: "<data>x\n <child/>y\n <child/>\n</data>\n",
|
||||
wantSecond: "<data>x\n \n <child/>y\n \n <child/>\n</data>\n",
|
||||
},
|
||||
{
|
||||
name: "pre-existing CRLF formatting is dropped and rebuilt",
|
||||
input: "<presentation>\r\n\t<slide/>\r\n</presentation>",
|
||||
want: "<presentation>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "multi-byte UTF-8 text and attributes keep exact bytes",
|
||||
input: `<presentation><title>原生图表 📊 Chart</title><slide 备注="中文värde"><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <title>原生图表 📊 Chart</title>\n <slide 备注=\"中文värde\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "namespace-prefixed p is still text-bearing",
|
||||
input: `<content xmlns:sml="urn:x"><sml:p><span>a</span> <span>b</span></sml:p></content>`,
|
||||
want: "<content xmlns:sml=\"urn:x\">\n <sml:p><span>a</span> <span>b</span></sml:p>\n</content>\n",
|
||||
},
|
||||
{
|
||||
name: "already formatted input is preserved",
|
||||
input: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
|
||||
want: "<presentation>\n <slide id=\"s1\">\n <shape id=\"a\">hello</shape>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
wantSecond := tt.wantSecond
|
||||
if wantSecond == "" {
|
||||
wantSecond = tt.want
|
||||
}
|
||||
again, err := prettyPrintXML(got)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
|
||||
}
|
||||
if again != wantSecond {
|
||||
t.Fatalf("second pass:\nonce: %q\ntwice: %q\nwant: %q", got, again, wantSecond)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged pins the cases where
|
||||
// slicing original bytes intentionally differs from the previous
|
||||
// etree-based parse-and-reserialize implementation. Each case preserves the
|
||||
// input MORE faithfully than before; none is covered by the original
|
||||
// contract tests. The etree field records the old output for the record.
|
||||
func TestPrettyPrintXMLPreservesLexicalFormsEtreeChanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string // current behavior: original bytes preserved
|
||||
etree string // what the etree-based implementation produced
|
||||
}{
|
||||
{
|
||||
name: "whitespace-only CDATA between structural children is kept",
|
||||
input: `<data><![CDATA[ ]]><child/></data>`,
|
||||
want: "<data><![CDATA[ ]]>\n <child/>\n</data>\n",
|
||||
etree: "<data>\n <child/>\n</data>\n",
|
||||
},
|
||||
{
|
||||
name: "empty element with explicit close tag is not collapsed",
|
||||
input: `<slide><data></data><shape/></slide>`,
|
||||
want: "<slide>\n <data></data>\n <shape/>\n</slide>\n",
|
||||
etree: "<slide>\n <data/>\n <shape/>\n</slide>\n",
|
||||
},
|
||||
{
|
||||
name: "non-whitespace character reference keeps its lexical form",
|
||||
input: `<presentation><title>A&中</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>A&中</title>\n <slide/>\n</presentation>\n",
|
||||
etree: "<presentation>\n <title>A&中</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "single-quoted attributes keep their quoting",
|
||||
input: `<presentation><slide id='s1'><shape/></slide></presentation>`,
|
||||
want: "<presentation>\n <slide id='s1'>\n <shape/>\n </slide>\n</presentation>\n",
|
||||
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "in-tag whitespace is preserved verbatim",
|
||||
input: "<presentation><slide id=\"s1\" ><shape/></slide ></presentation>",
|
||||
want: "<presentation>\n <slide id=\"s1\" >\n <shape/>\n </slide >\n</presentation>\n",
|
||||
etree: "<presentation>\n <slide id=\"s1\">\n <shape/>\n </slide>\n</presentation>\n",
|
||||
},
|
||||
{
|
||||
name: "literal > in leaf text is not re-escaped",
|
||||
input: `<presentation><title>a>b</title><slide/></presentation>`,
|
||||
want: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
|
||||
etree: "<presentation>\n <title>a>b</title>\n <slide/>\n</presentation>\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := prettyPrintXML(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(%q): %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("prettyPrintXML(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if tt.want == tt.etree {
|
||||
t.Fatalf("case is not a divergence: want == etree == %q", tt.want)
|
||||
}
|
||||
again, err := prettyPrintXML(got)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass, %q): %v", got, err)
|
||||
}
|
||||
if again != got {
|
||||
t.Fatalf("not idempotent:\nonce: %q\ntwice: %q", got, again)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadChartDemo reads the real-world chart demo shipped with the
|
||||
// lark-slides skill (~60KB, pretty-printed): the closest in-repo stand-in
|
||||
// for a full presentation read.
|
||||
func loadChartDemo(t testing.TB) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../skills/lark-slides/references/slides_chart_demo.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read chart demo fixture: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// minifyXML strips whitespace-only text children of structural (non
|
||||
// text-bearing, element-bearing) elements — the exact text nodes
|
||||
// prettyPrintXML treats as disposable formatting — producing the
|
||||
// single-line element shape the slides server actually returns.
|
||||
// Document-level tokens (prolog, trailing newline) pass through verbatim,
|
||||
// because the formatter preserves them verbatim too.
|
||||
func minifyXML(t testing.TB, input string) string {
|
||||
t.Helper()
|
||||
tokens, err := tokenize(input)
|
||||
if err != nil {
|
||||
t.Fatalf("tokenize for minify: %v", err)
|
||||
}
|
||||
var out strings.Builder
|
||||
var emitElement func(startIndex int)
|
||||
emitElement = func(startIndex int) {
|
||||
start := tokens[startIndex]
|
||||
end := tokens[start.match]
|
||||
if textBearingTags[start.local] || !hasElementChild(tokens, startIndex) {
|
||||
out.WriteString(input[start.start:end.end])
|
||||
return
|
||||
}
|
||||
out.WriteString(input[start.start:start.end])
|
||||
for i := startIndex + 1; i < start.match; {
|
||||
child := tokens[i]
|
||||
switch child.kind {
|
||||
case tokenCharData:
|
||||
if !isAllWhitespace(input[child.start:child.end]) {
|
||||
out.WriteString(input[child.start:child.end])
|
||||
}
|
||||
i++
|
||||
case tokenStartElement:
|
||||
emitElement(i)
|
||||
i = child.match + 1
|
||||
default:
|
||||
out.WriteString(input[child.start:child.end])
|
||||
i++
|
||||
}
|
||||
}
|
||||
out.WriteString(input[end.start:end.end])
|
||||
}
|
||||
for i := 0; i < len(tokens); {
|
||||
token := tokens[i]
|
||||
if token.kind == tokenStartElement {
|
||||
emitElement(i)
|
||||
i = token.match + 1
|
||||
continue
|
||||
}
|
||||
out.WriteString(input[token.start:token.end])
|
||||
i++
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// TestPrettyPrintXMLChartDemoFixture formats the real chart demo both as
|
||||
// shipped (pretty-printed) and minified to the single-line shape the server
|
||||
// returns; both must converge on the same idempotent output.
|
||||
func TestPrettyPrintXMLChartDemoFixture(t *testing.T) {
|
||||
original := loadChartDemo(t)
|
||||
|
||||
formattedOriginal, err := prettyPrintXML(original)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(original): %v", err)
|
||||
}
|
||||
twice, err := prettyPrintXML(formattedOriginal)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(second pass): %v", err)
|
||||
}
|
||||
if twice != formattedOriginal {
|
||||
t.Fatal("prettyPrintXML is not idempotent on the chart demo fixture")
|
||||
}
|
||||
|
||||
minified := minifyXML(t, original)
|
||||
if strings.Contains(minified, ">\n <") {
|
||||
t.Fatalf("minified fixture still contains structural indentation: %q", minified[:200])
|
||||
}
|
||||
// Only the doc-level newline after the XML declaration and the trailing
|
||||
// newline may remain; the whole element tree must be one line.
|
||||
if got := strings.Count(minified, "\n"); got > 2 {
|
||||
t.Fatalf("minified fixture has %d newlines, want <= 2", got)
|
||||
}
|
||||
formattedMinified, err := prettyPrintXML(minified)
|
||||
if err != nil {
|
||||
t.Fatalf("prettyPrintXML(minified): %v", err)
|
||||
}
|
||||
// Formatting drops exactly the whitespace minification dropped, so both
|
||||
// paths must converge on the same output.
|
||||
if formattedMinified != formattedOriginal {
|
||||
t.Fatal("format(minified) != format(original) for the chart demo fixture")
|
||||
}
|
||||
if !strings.Contains(formattedMinified, "\n <slide>") {
|
||||
t.Fatal("formatted chart demo lacks expected slide indentation")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrettyPrintXMLChartDemoMinified(b *testing.B) {
|
||||
minified := minifyXML(b, loadChartDemo(b))
|
||||
b.SetBytes(int64(len(minified)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := prettyPrintXML(minified); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrettyPrintXMLChartDemoPreformatted(b *testing.B) {
|
||||
original := loadChartDemo(b)
|
||||
b.SetBytes(int64(len(original)))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := prettyPrintXML(original); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -100,6 +101,40 @@ func extractTaskGuid(input string) string {
|
||||
return extractTasklistGuid(input)
|
||||
}
|
||||
|
||||
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
|
||||
|
||||
func parseTaskGUID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
invalid := func(format string, args ...interface{}) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
|
||||
WithParam("--task-id").
|
||||
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
return "", invalid("task ID is empty")
|
||||
}
|
||||
|
||||
lowerInput := strings.ToLower(input)
|
||||
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", invalid("invalid task applink: %v", err).WithCause(err)
|
||||
}
|
||||
guid := strings.TrimSpace(u.Query().Get("guid"))
|
||||
if guid == "" {
|
||||
return "", invalid("task applink is missing a non-empty guid query parameter")
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
|
||||
if taskDisplayNumberPattern.MatchString(input) {
|
||||
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := make(map[string]interface{})
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -15,3 +18,80 @@ func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTaskGUID(t *testing.T) {
|
||||
t.Run("accepts GUIDs and task applinks", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"},
|
||||
{name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"},
|
||||
{
|
||||
name: "task applink",
|
||||
input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123",
|
||||
want: "task-guid-123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseTaskGUID(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unusable task identifiers", func(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
"https://applink.larksuite.com/client/todo/detail",
|
||||
"https://%",
|
||||
"t12345",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := parseTaskGUID(input)
|
||||
if err == nil {
|
||||
t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input)
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatal("problem hint is empty")
|
||||
}
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != "--task-id" {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves applink parse cause", func(t *testing.T) {
|
||||
_, err := parseTaskGUID("https://%")
|
||||
if err == nil {
|
||||
t.Fatal("parseTaskGUID() error = nil, want URL parse error")
|
||||
}
|
||||
|
||||
var urlErr *url.Error
|
||||
if !errors.As(err, &urlErr) {
|
||||
t.Fatalf("error chain = %T %v, want *url.Error cause", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,45 +25,59 @@ var CompleteTask = common.Shortcut{
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "task-id", Desc: "task GUID or task applink URL", Required: true},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
return err
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCompleteBody()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskID := url.PathEscape(taskGUID)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/" + taskId).
|
||||
GET("/open-apis/task/v2/tasks/" + taskID).
|
||||
Desc("get current task status").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskID).
|
||||
Desc("complete task if not completed").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
taskGUID, err := parseTaskGUID(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := url.PathEscape(taskGUID)
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// 1. Get current task status
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskID, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskData, _ := getData["task"].(map[string]interface{})
|
||||
completedAtStr, _ := taskData["completed_at"].(string)
|
||||
alreadyCompleted := completedAtStr != "" && completedAtStr != "0"
|
||||
|
||||
// 2. If already completed, directly return success
|
||||
if completedAtStr != "" && completedAtStr != "0" {
|
||||
if alreadyCompleted {
|
||||
data = getData
|
||||
} else {
|
||||
// 3. Complete the task
|
||||
body := buildCompleteBody()
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,11 +87,19 @@ var CompleteTask = common.Shortcut{
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
completedAt, _ := task["completed_at"].(string)
|
||||
status := "todo"
|
||||
if completedAt != "" && completedAt != "0" {
|
||||
status = "done"
|
||||
}
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"status": status,
|
||||
"completed_at": completedAt,
|
||||
"already_completed": alreadyCompleted,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
@@ -45,6 +48,9 @@ func TestCompleteTask(t *testing.T) {
|
||||
formatFlag: "json",
|
||||
expectedOutput: []string{
|
||||
`"guid": "task-789"`,
|
||||
`"status": "done"`,
|
||||
`"completed_at": "1775174400000"`,
|
||||
`"already_completed": false`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -109,3 +115,98 @@ func TestCompleteTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteAcceptsTaskApplink(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
for _, method := range []string{"GET", "PATCH"} {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: method,
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-applink",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-applink",
|
||||
"summary": "Applink task",
|
||||
"completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method],
|
||||
"url": "https://example.com/task-guid-applink",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete",
|
||||
"--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) {
|
||||
t.Fatalf("output = %s, want normalized task GUID", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-done",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-done",
|
||||
"summary": "Already done",
|
||||
"completed_at": "1775174400000",
|
||||
"url": "https://example.com/task-guid-done",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true {
|
||||
t.Fatalf("completion state = %#v, want done/already_completed server state", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{
|
||||
"+complete", "--task-id", "t12345", "--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("CompleteTask error = nil, want invalid task ID error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
|
||||
t.Fatalf("error param = %#v, want --task-id", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ func splitAndTrimCSV(input string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildSearchPageParams(pageToken string) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
|
||||
@@ -37,6 +37,31 @@ func TestSplitAndTrimCSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchPageParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pageToken string
|
||||
wantToken string
|
||||
wantKey bool
|
||||
}{
|
||||
{name: "first page omits token"},
|
||||
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := buildSearchPageParams(tt.pageToken)
|
||||
got, present := params["page_token"]
|
||||
if present != tt.wantKey {
|
||||
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
|
||||
}
|
||||
if tt.wantKey && got != tt.wantToken {
|
||||
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -44,8 +44,10 @@ var SearchTask = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
@@ -74,9 +76,9 @@ var SearchTask = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,7 +92,7 @@ var SearchTask = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
@@ -183,9 +185,6 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
129
shortcuts/task/task_search_pagination_test.go
Normal file
129
shortcuts/task/task_search_pagination_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSearchPaginationUsesQueryToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
command string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "tasks",
|
||||
shortcut: SearchTask,
|
||||
command: "+search",
|
||||
url: "/open-apis/task/v2/tasks/search",
|
||||
},
|
||||
{
|
||||
name: "tasklists",
|
||||
shortcut: SearchTasklist,
|
||||
command: "+tasklist-search",
|
||||
url: "/open-apis/task/v2/tasklists/search",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
var pageTokens []string
|
||||
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
|
||||
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
|
||||
|
||||
shortcut := tt.shortcut
|
||||
shortcut.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, shortcut, []string{
|
||||
tt.command,
|
||||
"--query", "pagination",
|
||||
"--page-token", "initial_pt",
|
||||
"--page-limit", "2",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("search command failed: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"initial_pt", "next_pt"}
|
||||
if !reflect.DeepEqual(pageTokens, want) {
|
||||
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal search dry-run preview: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatalf("decode search dry-run preview: %v", err)
|
||||
}
|
||||
if len(envelope.API) != 1 {
|
||||
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
|
||||
}
|
||||
call := envelope.API[0]
|
||||
if got, _ := call.Params["page_token"].(string); got != want {
|
||||
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
|
||||
}
|
||||
if _, present := call.Body["page_token"]; present {
|
||||
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
|
||||
t.Helper()
|
||||
return &httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: endpoint,
|
||||
OnMatch: func(req *http.Request) {
|
||||
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read search request body: %v", err)
|
||||
return
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Errorf("decode search request body: %v", err)
|
||||
return
|
||||
}
|
||||
if _, present := payload["page_token"]; present {
|
||||
t.Errorf("search request body unexpectedly contains page_token: %s", body)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": hasMore,
|
||||
"page_token": responseToken,
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,12 @@ func TestBuildTaskSearchBody(t *testing.T) {
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
dueTime := filter["due_time"].(map[string]interface{})
|
||||
if body["query"] != "release" || body["page_token"] != "pt_123" {
|
||||
if body["query"] != "release" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if _, present := body["page_token"]; present {
|
||||
t.Fatalf("body unexpectedly contains page_token: %#v", body)
|
||||
}
|
||||
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
@@ -104,9 +107,10 @@ func TestBuildTaskSearchBody(t *testing.T) {
|
||||
|
||||
func TestSearchTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantPageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
@@ -114,7 +118,8 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
_ = cmd.Flags().Set("query", "demo")
|
||||
_ = cmd.Flags().Set("page-token", "pt_demo")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
||||
wantPageToken: "pt_demo",
|
||||
wantParts: []string{`"query":"demo"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid due",
|
||||
@@ -143,7 +148,11 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTask.DryRun(nil, runtime).Format()
|
||||
preview := SearchTask.DryRun(nil, runtime)
|
||||
if tt.wantPageToken != "" {
|
||||
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
|
||||
}
|
||||
out := preview.Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
|
||||
@@ -41,8 +41,10 @@ var SearchTasklist = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
@@ -71,9 +73,9 @@ var SearchTasklist = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +89,7 @@ var SearchTasklist = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
@@ -170,9 +172,6 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
createTime := filter["create_time"].(map[string]interface{})
|
||||
if body["page_token"] != "pt_tl" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
if _, present := body["page_token"]; present {
|
||||
t.Fatalf("body unexpectedly contains page_token: %#v", body)
|
||||
}
|
||||
if filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
@@ -80,9 +80,10 @@ func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
|
||||
func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantPageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
@@ -90,7 +91,8 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
_ = cmd.Flags().Set("query", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
wantPageToken: "pt_tl",
|
||||
wantParts: []string{`"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
@@ -116,7 +118,11 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
preview := SearchTasklist.DryRun(nil, runtime)
|
||||
if tt.wantPageToken != "" {
|
||||
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
|
||||
}
|
||||
out := preview.Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
|
||||
@@ -27,27 +27,42 @@ var UpdateTask = common.Shortcut{
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "task-id", Desc: "task GUID or task applink URL (comma-separated for multiple)", Required: true},
|
||||
{Name: "summary", Desc: "task title"},
|
||||
{Name: "description", Desc: "task description"},
|
||||
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
|
||||
{Name: "data", Desc: "JSON payload for task object"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
return err
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
preview := common.NewDryRunAPI()
|
||||
for _, taskID := range taskIDs {
|
||||
preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
return preview
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskIDs, err := parseTaskGUIDs(runtime.Str("task-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
// buildTaskUpdateBody already returns a typed validation error;
|
||||
@@ -55,17 +70,11 @@ var UpdateTask = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
var updatedTasks []map[string]interface{}
|
||||
|
||||
for _, taskId := range taskIds {
|
||||
taskId = strings.TrimSpace(taskId)
|
||||
if taskId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, taskID := range taskIDs {
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,19 +85,28 @@ var UpdateTask = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
updateFields, _ := body["update_fields"].([]string)
|
||||
var tasks []map[string]interface{}
|
||||
for _, task := range updatedTasks {
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
confirmed := make(map[string]interface{})
|
||||
for _, field := range updateFields {
|
||||
if value, ok := task[field]; ok {
|
||||
confirmed[field] = value
|
||||
}
|
||||
}
|
||||
tasks = append(tasks, map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
"confirmed": confirmed,
|
||||
})
|
||||
}
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"tasks": tasks,
|
||||
"updated_fields": updateFields,
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
|
||||
@@ -112,6 +130,26 @@ var UpdateTask = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func parseTaskGUIDs(input string) ([]string, error) {
|
||||
parts := strings.Split(input, ",")
|
||||
taskGUIDs := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
continue
|
||||
}
|
||||
guid, err := parseTaskGUID(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskGUIDs = append(taskGUIDs, guid)
|
||||
}
|
||||
if len(taskGUIDs) == 0 {
|
||||
_, err := parseTaskGUID("")
|
||||
return nil, err
|
||||
}
|
||||
return taskGUIDs, nil
|
||||
}
|
||||
|
||||
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
taskObj := make(map[string]interface{})
|
||||
var updateFields []string
|
||||
|
||||
201
shortcuts/task/task_update_test.go
Normal file
201
shortcuts/task/task_update_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseTaskGUIDs(t *testing.T) {
|
||||
got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("parseTaskGUIDs() error = %v", err)
|
||||
}
|
||||
want := []string{"task-guid-1", "task-guid-2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
_, err = parseTaskGUIDs("task-guid-1,t12345")
|
||||
if err == nil {
|
||||
t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "")
|
||||
cmd.Flags().String("summary", "updated", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("data", "", "")
|
||||
|
||||
preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd})
|
||||
payload, err := json.Marshal(preview)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run preview: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &got); err != nil {
|
||||
t.Fatalf("decode dry-run preview: %v", err)
|
||||
}
|
||||
if len(got.API) != 2 {
|
||||
t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload)
|
||||
}
|
||||
|
||||
wantURLs := []string{
|
||||
"/open-apis/task/v2/tasks/task-guid-1",
|
||||
"/open-apis/task/v2/tasks/task-guid-2",
|
||||
}
|
||||
for i, call := range got.API {
|
||||
if call.Method != "PATCH" {
|
||||
t.Errorf("api[%d].method = %q, want PATCH", i, call.Method)
|
||||
}
|
||||
if call.URL != wantURLs[i] {
|
||||
t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i])
|
||||
}
|
||||
if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) {
|
||||
t.Errorf("api[%d].params = %#v", i, call.Params)
|
||||
}
|
||||
if !reflect.DeepEqual(call.Body, got.API[0].Body) {
|
||||
t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
first := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-1",
|
||||
"url": "https://example.com/task-guid-1",
|
||||
"summary": "server summary one",
|
||||
"description": "server description one",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
second := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/task-guid-2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-guid-2",
|
||||
"url": "https://example.com/task-guid-2",
|
||||
"summary": "server summary two",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(first)
|
||||
reg.Register(second)
|
||||
|
||||
err := runMountedTaskShortcut(t, UpdateTask, []string{
|
||||
"+update",
|
||||
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2",
|
||||
"--summary", "requested summary",
|
||||
"--description", "requested description",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateTask error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", envelope["data"])
|
||||
}
|
||||
if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) {
|
||||
t.Fatalf("updated_fields = %v, want [summary description]", got)
|
||||
}
|
||||
|
||||
tasks, ok := data["tasks"].([]interface{})
|
||||
if !ok || len(tasks) != 2 {
|
||||
t.Fatalf("tasks = %#v, want two tasks", data["tasks"])
|
||||
}
|
||||
firstTask := tasks[0].(map[string]interface{})
|
||||
if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" {
|
||||
t.Fatalf("first task identifiers = %#v", firstTask)
|
||||
}
|
||||
if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
|
||||
"summary": "server summary one", "description": "server description one",
|
||||
}) {
|
||||
t.Fatalf("first confirmed = %#v", got)
|
||||
}
|
||||
|
||||
secondTask := tasks[1].(map[string]interface{})
|
||||
if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{
|
||||
"summary": "server summary two",
|
||||
}) {
|
||||
t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, UpdateTask, []string{
|
||||
"+update",
|
||||
"--task-id", "task-guid-1,t12345",
|
||||
"--summary", "must not be written",
|
||||
"--format", "json",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("UpdateTask error = nil, want invalid task ID error")
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("error = %T %v, want typed invalid-argument error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" {
|
||||
t.Fatalf("error param = %#v, want --task-id", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlice(value interface{}) []string {
|
||||
items, _ := value.([]interface{})
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
## 各命令
|
||||
|
||||
### +file-list
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20,范围 1..200)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-list --app-id app_xxx
|
||||
|
||||
@@ -29,7 +29,7 @@ metadata:
|
||||
## 使用边界
|
||||
|
||||
- Base 业务操作只使用 `lark-cli base +...` shortcut,不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`。
|
||||
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL;简单命令由命令自身的参数、tips 和错误恢复承接。
|
||||
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置,首次请求必须基于可信的当前配置执行 read-modify-write:只修改用户明确指定的内容,保留其他仍适用的可写配置,并按命令要求的结构提交。若命令支持局部/delta update,按其契约提交最小合法 payload;不得以不完整请求试错补参。
|
||||
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
|
||||
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`;Base 文档只保留会影响 Base 路径选择的权限规则。
|
||||
|
||||
@@ -104,19 +104,18 @@ metadata:
|
||||
|
||||
## 写入前置规则
|
||||
|
||||
- 更新前先看命令说明:需要完整提交时,先读取并补齐当前配置,只改用户指定的内容,再按命令要求提交;支持局部修改时,按命令说明和 reference 提交最小合法 payload。
|
||||
- 优先用写入返回确认结果;返回信息不足或任务明确要求核验时,再读回。
|
||||
- 写记录前先读字段结构;只写存储字段。系统字段、附件字段、`formula`、`lookup` 不作为普通记录写入目标。
|
||||
- 附件上传、下载、删除走专用 `+record-*-attachment` 命令。
|
||||
- 写字段前先读 [lark-base-field-json.md](references/lark-base-field-json.md);涉及 `formula` / `lookup` 时必须读 [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md)。
|
||||
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
|
||||
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
|
||||
- 删除、角色更新、字段更新、表单提交(`+form-submit`)等高风险操作遵循 CLI 的 confirmation gate,必须带 `--yes`;目标不明确时先用 get/list 消歧。
|
||||
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
|
||||
- `select` 字段只支持写入字段中已有的选项;构造 CellValue 前先用 `+field-list` 或 `+field-search-options` 确认目标选项存在。
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
通过表单分享链接填写并提交多维表格表单。仅支持分享模式(share_token),支持填写普通字段值和上传本地文件作为附件。
|
||||
|
||||
> **⚠️ 高风险写操作(high-risk-write):** 本命令会向表单写入并提交数据,属于高风险写操作,必须额外传递 `--yes` 进行确认,否则会返回 `confirmation_required` 错误并退出。当用户明确要求提交且目标表单无歧义时,直接附加 `--yes`,无需再次询问。
|
||||
|
||||
## 填写前必读:先获取表单详情
|
||||
|
||||
**在调用 `+form-submit` 之前,必须先使用 `+form-detail` 获取表单详情。** 原因如下:
|
||||
@@ -21,10 +23,11 @@ lark-cli base +form-detail --share-token <share_token>
|
||||
|
||||
# 2️⃣ 根据返回的 questions 列表,按 type 格式化值、检查 required、判断 filter 条件
|
||||
|
||||
# 3️⃣ 再提交
|
||||
# 3️⃣ 再提交(高风险写操作,必须带 --yes)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}'
|
||||
--json '{"fields":{...}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
`+form-detail` 的返回中要重点读取 `questions[].type`、`questions[].required`、题目 `filter` 和附件场景所需的 `data.base_token`。
|
||||
@@ -35,7 +38,8 @@ lark-cli base +form-submit \
|
||||
# 基本提交(填写普通字段)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}'
|
||||
--json '{"fields":{"服务评分":5,"评价内容":"服务态度好"}}' \
|
||||
--yes
|
||||
|
||||
# 带附件提交(需要额外提供 --base-token)
|
||||
lark-cli base +form-submit \
|
||||
@@ -47,15 +51,17 @@ lark-cli base +form-submit \
|
||||
"附件字段名": ["./report.pdf", "./photo.png"],
|
||||
"另一个附件字段": ["./doc.docx"]
|
||||
}
|
||||
}'
|
||||
}' \
|
||||
--yes
|
||||
|
||||
# 使用应用身份(bot)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
--as bot
|
||||
--as bot \
|
||||
--yes
|
||||
|
||||
# 预览 API 调用(不实际执行)
|
||||
# 预览 API 调用(不实际执行,dry-run 无需 --yes)
|
||||
lark-cli base +form-submit \
|
||||
--share-token <share_token> \
|
||||
--json '{"fields":{...}}' \
|
||||
@@ -69,6 +75,7 @@ lark-cli base +form-submit \
|
||||
| `--share-token <token>` | 是 | 表单分享 Token(必填),从表单分享链接中提取 |
|
||||
| `--base-token <token>` | 条件必填 | Base token;**当 `--json` 包含 `attachments` 时必须提供**,用于将附件上传到 Base Drive Media |
|
||||
| `--json <json>` | 是 | JSON 对象,包含 `"fields"`(普通字段值)和 `"attachments"`(附件上传),详见下方说明 |
|
||||
| `--yes` | 是 | 确认高风险写操作。本命令为 high-risk-write,不带 `--yes` 会返回 `confirmation_required` |
|
||||
| `--format` | 否 | 输出格式:json(默认)\| pretty \| table \| ndjson \| csv |
|
||||
| `--as` | 否 | 身份:user(默认)\| bot |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
@@ -138,7 +145,8 @@ https://www.example.com/share/base/form/shrbcvST8eZy0vk8zjVZ1CAXNye
|
||||
```bash
|
||||
lark-cli base +form-submit \
|
||||
--share-token shrbcvST8eZy0vk8zjVZ1CAXNye \
|
||||
--json '{"fields":{...}}'
|
||||
--json '{"fields":{...}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 输出格式
|
||||
@@ -158,6 +166,7 @@ lark-cli base +form-submit \
|
||||
|
||||
## 提示
|
||||
|
||||
- **本命令为高风险写操作(high-risk-write),必须额外传递 `--yes` 确认**,否则返回 `confirmation_required` 并以非零码退出;`--dry-run` 预览除外
|
||||
- 本命令仅支持通过表单分享链接(share_token)提交,不支持通过 base_token + table_id + view_id 方式提交
|
||||
- **当 `--json` 包含 `attachments` 时,必须额外提供 `--base-token`**,因为附件上传到 Base Drive Media 需要指定目标 Base
|
||||
- 附件字段只需在 `--json.attachments` 中提供本地路径即可,CLI 自动完成校验、并行上传、Token 获取和合并写入
|
||||
|
||||
@@ -13,7 +13,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
## 容器标签
|
||||
|标签|说明|关键属性|
|
||||
|-|-|-|
|
||||
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
|
||||
| `<callout>` | 高亮框,子块仅支持文本块(如 `<p>`)、标题、列表、待办、引用;禁止裸文本及 `<table>`、`<img>`、`<pre>`、`<hr>`、`<grid>`、`<whiteboard>`、`<sheet>` 等其他块级标签或资源块 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
|
||||
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
|
||||
| `<whiteboard>` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
|
||||
| `<pre>` | (代码块,内含 `code`)| `lang`, `caption` |
|
||||
|
||||
@@ -96,6 +96,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
- "某项目发布会重点" → 先搜项目名 + "发布会" + "重点/功能/一览",再按标题和摘要判断是否需要只搜标题或扩大到正文。
|
||||
|
||||
每轮扩展都要保留非污染、可解释的 evidence(URL/token/标题/摘要);不能因为某个扩展词搜到高相似标题就跳过证据核验。
|
||||
扩展 query 时,优先保留用户已经指定的空间、文件夹、群聊、人员、时间和类型等 filter;确需放宽检索范围时,先向用户说明原因并征得确认。
|
||||
|
||||
## 参数
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "notice_colorful" },
|
||||
"icon": { "tag": "standard_icon", "token": "lark-logo_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" }
|
||||
]
|
||||
|
||||
@@ -105,12 +105,12 @@
|
||||
"header": {
|
||||
"title": { "tag": "plain_text", "content": "卡片标题" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "mail_colorful" }
|
||||
"icon": { "tag": "standard_icon", "token": "calendar_colorful" }
|
||||
}
|
||||
```
|
||||
|
||||
- `token` 从 `resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称。
|
||||
- 常用速查:通知 `notice_colorful`、告警 `warning_colorful`、审批 `approve_colorful`、日历 `calendar_colorful`、数据 `chart_colorful`、任务 `todo_colorful`、AI `myai_colorful`。
|
||||
- `token` 必须从 `resource/icons.md` 的精确枚举中选择;禁止根据名称规律自行拼接 token。没有合适的 token 时省略 icon。
|
||||
- 场景速查:日历 `calendar_colorful`、待办 `todo_colorful`、投票 `vote_colorful`、妙记 `file-lark-minutes_colorful`、多维表格 `wiki-bitable_colorful`、表单 `file-form_colorful`、社区 `larkcommunity_colorful`、招聘 `hirelogo_colorful`、飞书品牌 `lark-logo_colorful`、Meego `meego_colorful`、AI `myai_colorful`、aPaaS `apaas_colorful`、审批 `approval_colorful`、通用 AI `ai-common_colorful`。
|
||||
|
||||
### 1. 配色纪律(服务 P6 语义一致)
|
||||
|
||||
@@ -212,7 +212,7 @@ header 有三层能力,**尽量用满**(至少用 `title` + `icon`;`subtit
|
||||
"title": { "tag": "plain_text", "content": "发版审批" },
|
||||
"subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" },
|
||||
"template": "blue",
|
||||
"icon": { "tag": "standard_icon", "token": "approve_colorful" },
|
||||
"icon": { "tag": "standard_icon", "token": "approval_colorful" },
|
||||
"text_tag_list": [
|
||||
{ "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" }
|
||||
]
|
||||
|
||||
@@ -34,5 +34,19 @@
|
||||
| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` |
|
||||
| 附件 | `attachment_outlined` | 审批 | `approval_outlined` |
|
||||
|
||||
## 彩色图标(精确 token)
|
||||
|
||||
彩色图标必须从下表按**完整字符串**选择,禁止根据名称规律自行拼接。彩色 token 自带颜色,不要再推导其他后缀或变体。
|
||||
|
||||
| 含义 | token | 含义 | token |
|
||||
|---|---|---|---|
|
||||
| 日历 | `calendar_colorful` | 待办 | `todo_colorful` |
|
||||
| 投票 | `vote_colorful` | 飞书妙记 | `file-lark-minutes_colorful` |
|
||||
| 多维表格 | `wiki-bitable_colorful` | 表单 | `file-form_colorful` |
|
||||
| 飞书社区 | `larkcommunity_colorful` | 招聘 | `hirelogo_colorful` |
|
||||
| 飞书品牌 | `lark-logo_colorful` | Meego | `meego_colorful` |
|
||||
| AI | `myai_colorful` | aPaaS | `apaas_colorful` |
|
||||
| 审批 | `approval_colorful` | 通用 AI | `ai-common_colorful` |
|
||||
|
||||
> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准:
|
||||
> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons
|
||||
|
||||
@@ -101,9 +101,9 @@ metadata:
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口。**
|
||||
**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。**
|
||||
|
||||
**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、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -26,8 +26,8 @@ lark-cli slides +screenshot --as user \
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-id slide_1,slide_2`);一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-number 1,2,3`);一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10) |
|
||||
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
|
||||
| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
@@ -44,7 +44,7 @@ lark-cli slides +screenshot --as user \
|
||||
|
||||
### 多页截图
|
||||
|
||||
一次不要超过 10 页;如需更多页面,分批调用。
|
||||
一次不要超过 10 页;如需更多页面,分批调用。可以重复传参,也可以用逗号分隔一次传多个:
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
2. 用 `slides +xml-get` 回读,确认是否已有部分页面写入。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。
|
||||
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 `xml_text_overlap_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核。
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
|
||||
@@ -25,19 +25,32 @@ lark-cli slides +xml-get --as user \
|
||||
--json
|
||||
```
|
||||
|
||||
## Automated XML Text Overlap Lint
|
||||
## Automated XML Layout Lint
|
||||
|
||||
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
|
||||
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
|
||||
```
|
||||
|
||||
通过标准:
|
||||
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
准出规则:
|
||||
|
||||
- `summary.error_count > 0` 或 `summary.release_ready == false`:阻断创建、替换或交付,必须先修复。
|
||||
- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。
|
||||
- `slides[].status` 为 `blocked`、`needs_screenshot_review` 或 `passed`,可直接决定逐页后续动作。
|
||||
- CLI 在存在 `error` 时退出码为 1;只有 `warning` 时仍输出 JSON 并退出 0,供截图复核链路继续执行。
|
||||
|
||||
每条 `error` / `warning` 都包含:
|
||||
|
||||
- `element_ids`:相关 XML 元素 ID;
|
||||
- `rule`:规则 ID、名称、阈值和比较关系;
|
||||
- `measurement`:越界量、交叠面积、覆盖率等实测值;
|
||||
- `related_objects`:相关对象的类型与坐标框;
|
||||
- `target`、`message`、`hint`:页码、语义说明和处理建议。
|
||||
|
||||
当 `sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
@@ -51,6 +64,10 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
|
||||
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
|
||||
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
|
||||
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
|
||||
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |
|
||||
|
||||
## Screenshot QA
|
||||
|
||||
|
||||
@@ -188,6 +188,13 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<shadow>`
|
||||
- `<content>`
|
||||
|
||||
`type` 常用取值:`text`(文本框)、`rect`、`round-rect`(圆角矩形)、`ellipse`(椭圆/圆)、`triangle`、`diamond`、`parallelogram`、`trapezoid`、`custom`(配合 `path` 属性写 SVG 路径串)。箭头、星形、标注气泡、`chevron`、`flow-chart-*` 等更多形状见 XSD `ShapeType` 枚举。
|
||||
|
||||
其它可选属性:
|
||||
|
||||
- `presetHandlers`:控制点,用于圆角等。例如 `<shape type="rect" presetHandlers="60">` = 圆角半径 60px 的圆角矩形;多个控制点用逗号分隔。
|
||||
- `path`:仅 `type="custom"` 时使用,SVG 路径串。
|
||||
|
||||
### line
|
||||
|
||||
```xml
|
||||
@@ -198,6 +205,16 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
|
||||
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。
|
||||
|
||||
### polyline
|
||||
|
||||
折线 / 曲线连接线,用外接矩形定位(`topLeftX` / `topLeftY` / `width` / `height`),不是端点坐标;`<border>` 必填(无 border 不可见)。`type` 默认 `bent-connector2`(可选 `bent-connector2-5` 折线 / `curved-connector2-5` 曲线)。
|
||||
|
||||
```xml
|
||||
<polyline topLeftX="120" topLeftY="120" width="200" height="100">
|
||||
<border color="rgb(43, 47, 54)" width="2"/>
|
||||
</polyline>
|
||||
```
|
||||
|
||||
### img
|
||||
|
||||
```xml
|
||||
@@ -238,6 +255,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
- `<colgroup>` 直接子元素只有 `<col width="...">`,width 定义列宽,默认 110。
|
||||
- `<tr height="...">` 直接子元素只有 `<td>`,height 定义行高,默认 37。
|
||||
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>`、`<img>`、`<icon>`。
|
||||
- 合并单元格:`<td>` 上用 `colspan`(跨列,默认 1)和 `rowspan`(跨行,默认 1);被合并覆盖的单元格不再写对应 `<td>`。
|
||||
|
||||
表头默认的白底白字视觉效果极差,必须设置背景和文字颜色,需在首行每个 `<td>` 上加 `<fill>`(配合 `bold` 与对比文字色)与正文行区分。
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Validate Slides XML structure and page layout through one release gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -42,18 +43,25 @@ ROUNDTRIP_SXSD_ATTRS = {
|
||||
("chart", "updated"),
|
||||
("chartData", "isStaticData"),
|
||||
}
|
||||
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
|
||||
# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
|
||||
# deck, so treating it as an unsupported tag would block per-slide linting document-wide.
|
||||
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
|
||||
DEFAULT_TABLE_COLUMN_WIDTH = 110
|
||||
DEFAULT_TABLE_ROW_HEIGHT = 37
|
||||
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
|
||||
# visible defect; keep this well under 1px so real overflow is still always caught.
|
||||
CANVAS_OVERFLOW_TOLERANCE = 0.5
|
||||
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
|
||||
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
|
||||
|
||||
|
||||
class XmlTextOverlapLintError(Exception):
|
||||
class XmlLayoutLintError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise XmlTextOverlapLintError(message)
|
||||
raise XmlLayoutLintError(message)
|
||||
|
||||
|
||||
def read_file(file_path: str | Path) -> str:
|
||||
@@ -79,8 +87,12 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def extract_attribute(tag_source: str, name: str) -> str | None:
|
||||
match = re.search(fr'{re.escape(name)}="([^"]+)"', tag_source)
|
||||
return match.group(1) if match else None
|
||||
match = re.search(
|
||||
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1) if match.group(1) is not None else match.group(2)
|
||||
|
||||
|
||||
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
||||
@@ -372,6 +384,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
|
||||
|
||||
tag_name = xml_local_name(element.tag)
|
||||
current_path = f"{path}/{tag_name}" if path else tag_name
|
||||
if tag_name in ROUNDTRIP_SXSD_TAGS:
|
||||
return
|
||||
if tag_name not in supported_tags:
|
||||
issues.append(
|
||||
{
|
||||
@@ -632,8 +646,9 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
|
||||
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
|
||||
kind, attrs = match.group(1), match.group(2)
|
||||
is_self_closing = attrs.rstrip().endswith("/")
|
||||
content = ""
|
||||
if kind in {"shape", "table"}:
|
||||
if kind in {"shape", "table"} and not is_self_closing:
|
||||
close_index = slide_xml.find(f"</{kind}>", match.end())
|
||||
if close_index != -1:
|
||||
content = slide_xml[match.end() : close_index]
|
||||
@@ -1039,6 +1054,19 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
|
||||
return vertical_overlap >= min_vertical_overlap
|
||||
|
||||
|
||||
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
|
||||
source, target = sorted([left, right], key=lambda element: element["x"])
|
||||
visual_width = estimate_text_max_line_width(source)
|
||||
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
|
||||
width = intersection_width(source_visual_bbox, target)
|
||||
height = intersection_height(source_visual_bbox, target)
|
||||
return {
|
||||
"intersection_width": round(width, 3),
|
||||
"intersection_height": round(height, 3),
|
||||
"intersection_area": round(width * height, 3),
|
||||
}
|
||||
|
||||
|
||||
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
if is_text_element(left) and not has_text_content(left):
|
||||
return False
|
||||
@@ -1166,9 +1194,6 @@ def detect_whiteboard_external_overlaps(
|
||||
|
||||
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
|
||||
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
|
||||
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
|
||||
return bbox
|
||||
|
||||
rotation = element["rotation"]
|
||||
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
|
||||
rotation = 0
|
||||
@@ -1194,12 +1219,7 @@ def detect_elements_out_of_canvas(
|
||||
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for element in (
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] in {"table", "chart"}
|
||||
or (element["kind"] == "shape" and element["type"] == "text")
|
||||
):
|
||||
for element in elements:
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
@@ -1208,7 +1228,9 @@ def detect_elements_out_of_canvas(
|
||||
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
|
||||
}
|
||||
overflow_details = [
|
||||
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
|
||||
f"{side} by {amount:g}px"
|
||||
for side, amount in overflow.items()
|
||||
if amount > CANVAS_OVERFLOW_TOLERANCE
|
||||
]
|
||||
if not overflow_details:
|
||||
continue
|
||||
@@ -1330,62 +1352,714 @@ def lint_slide(
|
||||
"code": "bbox_overlap",
|
||||
"elements": [left["id"], right["id"]],
|
||||
"message": f'{left["id"]} overlaps {right["id"]}',
|
||||
"hint": "Move or resize the elements so their visual bounds no longer intersect.",
|
||||
**(
|
||||
{"measurement": horizontal_text_overflow_measurement(left, right)}
|
||||
if horizontal_overflow
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {"slide_number": slide_number, "element_count": len(elements), "issues": issues}
|
||||
return {
|
||||
"slide_number": slide_number,
|
||||
"element_count": len(elements),
|
||||
"elements": elements,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
root, xml_error = parse_xml_root(xml)
|
||||
if xml_error:
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": 960, "height": 540},
|
||||
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
|
||||
"issues": [xml_error],
|
||||
"slides": [],
|
||||
}
|
||||
|
||||
namespace_issues = validate_sml_tag_prefixes(xml)
|
||||
sxsd_issues = validate_sxsd_tag_attributes(root) if root is not None else []
|
||||
iconpark_issues = validate_iconpark_icon_types(root) if root is not None else []
|
||||
top_level_issues = [*namespace_issues, *sxsd_issues, *iconpark_issues]
|
||||
if namespace_issues:
|
||||
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
||||
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
||||
return {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": 960, "height": 540},
|
||||
"summary": {
|
||||
"slide_count": 0,
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
},
|
||||
"issues": top_level_issues,
|
||||
"slides": [],
|
||||
}
|
||||
presentation = parse_presentation(xml)
|
||||
slides = [
|
||||
lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
|
||||
for index, slide_xml in enumerate(presentation["slides"])
|
||||
MIN_CONTAINER_WIDTH = 140
|
||||
MIN_CONTAINER_HEIGHT = 160
|
||||
MIN_SHORT_CARD_HEIGHT = 80
|
||||
MIN_CONTAINER_AREA = 20_000
|
||||
MIN_CONTENT_COVERAGE_RATIO = 0.15
|
||||
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
|
||||
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
|
||||
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
|
||||
MIN_SIMILAR_SHORT_CARD_COUNT = 2
|
||||
LARGE_VISUAL_CHILD_RATIO = 0.35
|
||||
LAYOUT_PANEL_SPAN_RATIO = 0.90
|
||||
IMAGE_OVERLAY_MATCH_RATIO = 0.90
|
||||
DENSITY_CONTAINMENT_TOLERANCE = 8
|
||||
|
||||
|
||||
def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
left = max(element["x"], container["x"])
|
||||
top = max(element["y"], container["y"])
|
||||
right = min(element["x"] + element["width"], container["x"] + container["width"])
|
||||
bottom = min(element["y"] + element["height"], container["y"] + container["height"])
|
||||
if right <= left or bottom <= top:
|
||||
return None
|
||||
return {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
||||
|
||||
|
||||
def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
|
||||
x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
|
||||
area = 0
|
||||
for left, right in zip(x_coordinates, x_coordinates[1:]):
|
||||
intervals = sorted(
|
||||
(rect["y"], rect["y"] + rect["height"])
|
||||
for rect in rectangles
|
||||
if rect["x"] < right and rect["x"] + rect["width"] > left
|
||||
)
|
||||
covered_height = 0
|
||||
interval_end: int | float | None = None
|
||||
for top, bottom in intervals:
|
||||
if interval_end is None:
|
||||
covered_height += bottom - top
|
||||
interval_end = bottom
|
||||
elif bottom > interval_end:
|
||||
covered_height += bottom - max(top, interval_end)
|
||||
interval_end = bottom
|
||||
area += (right - left) * covered_height
|
||||
return area
|
||||
|
||||
|
||||
def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
|
||||
return sum(
|
||||
other is not element
|
||||
and is_visually_rendered(other)
|
||||
and other["kind"] == "shape"
|
||||
and other["type"] == "rect"
|
||||
and other["width"] >= MIN_CONTAINER_WIDTH
|
||||
and other["height"] >= MIN_SHORT_CARD_HEIGHT
|
||||
and element_area(other) >= MIN_CONTAINER_AREA
|
||||
and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
|
||||
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
|
||||
and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
|
||||
<= SHORT_CARD_SIZE_TOLERANCE_RATIO
|
||||
for other in elements
|
||||
) >= MIN_SIMILAR_SHORT_CARD_COUNT
|
||||
|
||||
|
||||
def is_layout_container(
|
||||
element: dict[str, Any],
|
||||
slide_width: int | float,
|
||||
slide_height: int | float,
|
||||
elements: list[dict[str, Any]] | None = None,
|
||||
) -> bool:
|
||||
has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
|
||||
elements is not None
|
||||
and element["height"] >= MIN_SHORT_CARD_HEIGHT
|
||||
and has_similar_short_card_peer(element, elements)
|
||||
)
|
||||
return (
|
||||
element["kind"] == "shape"
|
||||
and element["type"] == "rect"
|
||||
and is_visually_rendered(element)
|
||||
and element["width"] >= MIN_CONTAINER_WIDTH
|
||||
and has_supported_height
|
||||
and element_area(element) >= MIN_CONTAINER_AREA
|
||||
and not (
|
||||
element["x"] <= 2
|
||||
and element["y"] <= 2
|
||||
and element["width"] >= slide_width - 4
|
||||
and element["height"] >= slide_height - 4
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_edge_spanning_layout_panel(
|
||||
element: dict[str, Any], slide_width: int | float, slide_height: int | float
|
||||
) -> bool:
|
||||
touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
|
||||
touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
|
||||
return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
|
||||
touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
|
||||
)
|
||||
|
||||
|
||||
def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
|
||||
container_area = element_area(container)
|
||||
return any(
|
||||
element["kind"] == "img"
|
||||
and is_visually_rendered(element)
|
||||
and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
|
||||
for element in elements
|
||||
)
|
||||
|
||||
|
||||
def is_nested_in_layout_panel(
|
||||
container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
|
||||
) -> bool:
|
||||
return any(
|
||||
element is not container
|
||||
and element["kind"] == "shape"
|
||||
and element["type"] == "rect"
|
||||
and is_visually_rendered(element)
|
||||
and is_edge_spanning_layout_panel(element, slide_width, slide_height)
|
||||
and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
|
||||
for element in elements
|
||||
)
|
||||
|
||||
|
||||
def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements = extract_elements(slide_xml)
|
||||
elements_by_id = {element["id"]: element for element in elements}
|
||||
root = ET.fromstring(slide_xml)
|
||||
for node in root.iter():
|
||||
if xml_local_name(node.tag) != "shape":
|
||||
continue
|
||||
element = elements_by_id.get(node.attrib.get("id", ""))
|
||||
if element is None:
|
||||
continue
|
||||
content_node = next(
|
||||
(child for child in node if xml_local_name(child.tag) == "content"),
|
||||
None,
|
||||
)
|
||||
paragraphs = (
|
||||
[
|
||||
" ".join("".join(paragraph.itertext()).split())
|
||||
for paragraph in content_node.iter()
|
||||
if xml_local_name(paragraph.tag) == "p"
|
||||
]
|
||||
if content_node is not None
|
||||
else []
|
||||
)
|
||||
raw_font_size = (
|
||||
content_node.attrib.get("fontSize") if content_node is not None else None
|
||||
) or node.attrib.get("fontSize")
|
||||
try:
|
||||
base_font_size = float(raw_font_size or 16)
|
||||
except ValueError:
|
||||
base_font_size = 16.0
|
||||
element.update(
|
||||
{
|
||||
"textType": content_node.attrib.get("textType") if content_node is not None else None,
|
||||
"textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
|
||||
"autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
|
||||
"fontSize": base_font_size,
|
||||
"text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
|
||||
}
|
||||
)
|
||||
if not has_text_content(element):
|
||||
continue
|
||||
declared_font_sizes = []
|
||||
for descendant in node.iter():
|
||||
raw_declared_font_size = descendant.attrib.get("fontSize")
|
||||
if raw_declared_font_size is None:
|
||||
continue
|
||||
try:
|
||||
declared_font_sizes.append(float(raw_declared_font_size))
|
||||
except ValueError:
|
||||
continue
|
||||
if declared_font_sizes:
|
||||
element["fontSize"] = max(declared_font_sizes)
|
||||
for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
x = extract_numeric_attribute(attrs, "topLeftX")
|
||||
y = extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = extract_numeric_attribute(attrs, "width")
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
if any(value is None for value in (x, y, width, height)):
|
||||
continue
|
||||
icon_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
|
||||
"kind": "icon",
|
||||
"type": "icon",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
|
||||
"alpha": icon_alpha if icon_alpha is not None else 1,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
x = extract_numeric_attribute(attrs, "topLeftX")
|
||||
y = extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = extract_numeric_attribute(attrs, "width")
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
if any(value is None for value in (x, y, width, height)):
|
||||
continue
|
||||
polyline_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
|
||||
"kind": "polyline",
|
||||
"type": "polyline",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"rotation": extract_numeric_attribute(attrs, "rotation") or 0,
|
||||
"alpha": polyline_alpha if polyline_alpha is not None else 1,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
for line_element in extract_line_elements(slide_xml):
|
||||
line_element["order"] = len(elements)
|
||||
elements.append(line_element)
|
||||
return elements
|
||||
|
||||
|
||||
def is_visually_rendered(element: dict[str, Any]) -> bool:
|
||||
return element.get("alpha", 1) > 0
|
||||
|
||||
|
||||
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if not is_visually_rendered(element):
|
||||
return None
|
||||
if is_text_element(element):
|
||||
estimated = estimate_text_visual_bbox(element)
|
||||
return clipped_bbox(estimated, container) if estimated else None
|
||||
return clipped_bbox(element, container)
|
||||
|
||||
|
||||
def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
|
||||
if container["kind"] != "shape" or not has_text_content(container):
|
||||
return None
|
||||
text_proxy = {**container, "type": "text"}
|
||||
estimated = estimate_text_visual_bbox(text_proxy)
|
||||
return clipped_bbox(estimated, container) if estimated else None
|
||||
|
||||
|
||||
def slide_content_visual_bbox(
|
||||
element: dict[str, Any], slide_bbox: dict[str, int | float]
|
||||
) -> dict[str, int | float] | None:
|
||||
if not is_visually_rendered(element):
|
||||
return None
|
||||
if is_text_element(element):
|
||||
estimated = estimate_text_visual_bbox(element)
|
||||
return clipped_bbox(estimated, slide_bbox) if estimated else None
|
||||
if element["kind"] == "shape" and has_text_content(element):
|
||||
estimated = own_text_visual_bbox(element)
|
||||
return clipped_bbox(estimated, slide_bbox) if estimated else None
|
||||
if element["kind"] == "line":
|
||||
# a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
|
||||
# treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
|
||||
return clipped_bbox(line_stroke_bbox(element), slide_bbox)
|
||||
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
|
||||
return clipped_bbox(element, slide_bbox)
|
||||
return None
|
||||
|
||||
|
||||
def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
|
||||
|
||||
|
||||
def is_slide_content_present(
|
||||
element: dict[str, Any], slide_bbox: dict[str, int | float]
|
||||
) -> bool:
|
||||
# Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
|
||||
# *anything* rendered here", not the richer "counts toward meaningful content density" bar
|
||||
# that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
|
||||
# decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
|
||||
# count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
|
||||
# instead of maintaining an allow-list that silently treats unlisted kinds as blank.
|
||||
if not is_visually_rendered(element):
|
||||
return False
|
||||
if (
|
||||
element["kind"] == "shape"
|
||||
and element["type"] == "rect"
|
||||
and not has_text_content(element)
|
||||
and element["x"] <= 2
|
||||
and element["y"] <= 2
|
||||
and element["width"] >= slide_bbox["width"] - 4
|
||||
and element["height"] >= slide_bbox["height"] - 4
|
||||
):
|
||||
# A full-canvas plain rect is a background panel, not content -- same reasoning as
|
||||
# is_layout_container's existing background exclusion. A slide with nothing else on it
|
||||
# is still effectively blank.
|
||||
return False
|
||||
bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
|
||||
return clipped_bbox(bbox, slide_bbox) is not None
|
||||
|
||||
|
||||
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
|
||||
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
|
||||
return False
|
||||
if not is_visually_rendered(element):
|
||||
return False
|
||||
return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO
|
||||
|
||||
|
||||
def detect_sparse_container_content(
|
||||
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
for container in (
|
||||
element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
|
||||
):
|
||||
if (
|
||||
is_edge_spanning_layout_panel(container, slide_width, slide_height)
|
||||
or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
|
||||
or has_matching_image_overlay(container, elements)
|
||||
):
|
||||
continue
|
||||
children = [
|
||||
element
|
||||
for element in elements
|
||||
if element is not container
|
||||
and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
|
||||
]
|
||||
if any(is_large_visual_child(child, container) for child in children):
|
||||
continue
|
||||
own_text_bbox = own_text_visual_bbox(container)
|
||||
rectangles = ([own_text_bbox] if own_text_bbox else []) + [
|
||||
bbox for child in children if (bbox := visual_bbox(child, container)) is not None
|
||||
]
|
||||
content_area = rectangle_union_area(rectangles) if rectangles else 0
|
||||
coverage_ratio = content_area / element_area(container)
|
||||
if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "sparse_container_content",
|
||||
"target": {
|
||||
"slide_number": slide_number,
|
||||
"container_id": container["id"],
|
||||
"container_type": container["type"],
|
||||
"bbox": {key: container[key] for key in ("x", "y", "width", "height")},
|
||||
},
|
||||
"rule": {
|
||||
"name": "large_container_visible_content_coverage",
|
||||
"threshold": MIN_CONTENT_COVERAGE_RATIO,
|
||||
"comparison": "content_coverage_ratio < threshold",
|
||||
},
|
||||
"measurement": {
|
||||
"container_area": element_area(container),
|
||||
"visible_content_area": round(content_area, 3),
|
||||
"content_coverage_ratio": round(coverage_ratio, 3),
|
||||
"content_element_count": len(children) + (1 if own_text_bbox else 0),
|
||||
},
|
||||
"elements": [container["id"], *[child["id"] for child in children]],
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def detect_sparse_slide_content(
|
||||
elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
|
||||
) -> list[dict[str, Any]]:
|
||||
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
|
||||
content = [
|
||||
(element, bbox)
|
||||
for element in elements
|
||||
if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
|
||||
]
|
||||
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
|
||||
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
|
||||
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
|
||||
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
|
||||
result = {
|
||||
if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
|
||||
return []
|
||||
content_area = rectangle_union_area([bbox for _, bbox in content])
|
||||
slide_area = slide_width * slide_height
|
||||
coverage_ratio = content_area / slide_area
|
||||
if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "sparse_slide_content",
|
||||
"target": {
|
||||
"slide_number": slide_number,
|
||||
"bbox": slide_bbox,
|
||||
},
|
||||
"rule": {
|
||||
"name": "slide_visible_content_coverage",
|
||||
"threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
|
||||
"comparison": "content_coverage_ratio < threshold",
|
||||
},
|
||||
"measurement": {
|
||||
"slide_area": slide_area,
|
||||
"visible_content_area": round(content_area, 3),
|
||||
"content_coverage_ratio": round(coverage_ratio, 3),
|
||||
"content_element_count": len(content),
|
||||
},
|
||||
"elements": [element["id"] for element, _ in content],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def detect_blank_slide(
|
||||
elements: list[dict[str, Any]],
|
||||
slide_number: int,
|
||||
slide_width: int | float,
|
||||
slide_height: int | float,
|
||||
) -> list[dict[str, Any]]:
|
||||
slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
|
||||
visible_elements = [
|
||||
element for element in elements if is_slide_content_present(element, slide_bbox)
|
||||
]
|
||||
if visible_elements:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"level": "error",
|
||||
"code": "blank_slide",
|
||||
"schema_version": "2.0",
|
||||
"target": {"slide_number": slide_number},
|
||||
"rule": {
|
||||
"name": "slide_has_visible_content",
|
||||
"comparison": "visible_element_count == 0",
|
||||
},
|
||||
"measurement": {
|
||||
"visible_element_count": 0,
|
||||
"declared_element_count": len(elements),
|
||||
},
|
||||
"elements": [element["id"] for element in elements],
|
||||
"message": "slide has no visible content beyond empty layout shapes",
|
||||
"hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
RULE_METADATA: dict[str, dict[str, Any]] = {
|
||||
"xml_not_well_formed": {
|
||||
"name": "xml_is_well_formed",
|
||||
"comparison": "xml_parse_error == false",
|
||||
},
|
||||
"sml_prefixed_tag": {
|
||||
"name": "sml_uses_default_namespace",
|
||||
"comparison": "prefixed_sml_tag_count == 0",
|
||||
},
|
||||
"sxsd_unsupported_tag": {
|
||||
"name": "tag_is_supported_by_slides_xml_schema",
|
||||
"comparison": "unsupported_tag_count == 0",
|
||||
},
|
||||
"sxsd_unsupported_attr": {
|
||||
"name": "attribute_is_supported_by_slides_xml_schema",
|
||||
"comparison": "unsupported_attribute_count == 0",
|
||||
},
|
||||
"icon_missing_fill_color": {
|
||||
"name": "icon_has_visible_fill_color",
|
||||
"comparison": "fill_color_present == true",
|
||||
},
|
||||
"icon_transparent_fill_color": {
|
||||
"name": "icon_has_visible_fill_color",
|
||||
"comparison": "fill_alpha > 0",
|
||||
},
|
||||
"iconpark_unsupported_icon_type": {
|
||||
"name": "iconpark_type_is_supported",
|
||||
"comparison": "icon_type in iconpark_index",
|
||||
},
|
||||
"bbox_overlap": {
|
||||
"name": "text_visual_bounds_do_not_overlap",
|
||||
"comparison": "intersection_area == 0",
|
||||
},
|
||||
"text_may_overflow_shape": {
|
||||
"name": "estimated_text_fits_declared_shape",
|
||||
"comparison": "estimated_height <= available_height",
|
||||
},
|
||||
"whiteboard_external_overlap": {
|
||||
"name": "whiteboard_does_not_cross_sibling_content",
|
||||
"comparison": "external_overlap_count == 0",
|
||||
},
|
||||
"image_covers_text": {
|
||||
"name": "image_does_not_cover_text",
|
||||
"comparison": "intersection_area == 0",
|
||||
},
|
||||
"image_may_cover_vertical_text": {
|
||||
"name": "image_vertical_text_occlusion_requires_review",
|
||||
"comparison": "intersection_area == 0",
|
||||
},
|
||||
"table_resolved_size_mismatch": {
|
||||
"name": "table_declared_size_matches_resolved_grid",
|
||||
"comparison": "declared_size == resolved_size",
|
||||
},
|
||||
"blank_slide": {
|
||||
"name": "slide_has_visible_content",
|
||||
"comparison": "visible_element_count > 0",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
|
||||
if issue.get("rule"):
|
||||
return {**issue["rule"], "id": issue["code"]}
|
||||
if issue["code"].endswith("_out_of_canvas"):
|
||||
return {
|
||||
"id": issue["code"],
|
||||
"name": "element_stays_within_slide_canvas",
|
||||
"comparison": "max(left, top, right, bottom overflow) == 0",
|
||||
}
|
||||
return {
|
||||
"id": issue["code"],
|
||||
**RULE_METADATA.get(
|
||||
issue["code"],
|
||||
{"name": issue["code"], "comparison": "violation_count == 0"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def issue_measurement(
|
||||
issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
if issue.get("measurement") is not None:
|
||||
return issue["measurement"]
|
||||
if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
|
||||
left = elements_by_id.get(issue["elements"][0])
|
||||
right = elements_by_id.get(issue["elements"][1])
|
||||
if left and right:
|
||||
left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
|
||||
right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
|
||||
width = intersection_width(left_box, right_box)
|
||||
height = intersection_height(left_box, right_box)
|
||||
return {
|
||||
"intersection_width": round(width, 3),
|
||||
"intersection_height": round(height, 3),
|
||||
"intersection_area": round(width * height, 3),
|
||||
}
|
||||
if issue["code"].endswith("_out_of_canvas"):
|
||||
return {
|
||||
"canvas": issue.get("canvas"),
|
||||
"bbox": issue.get("bbox"),
|
||||
"overflow": issue.get("overflow"),
|
||||
}
|
||||
measurement_keys = (
|
||||
"line",
|
||||
"column",
|
||||
"tag",
|
||||
"attr",
|
||||
"iconType",
|
||||
"line_count",
|
||||
"line_height",
|
||||
"estimated_height",
|
||||
"available_height",
|
||||
"overflow",
|
||||
"dimension",
|
||||
"declared_size",
|
||||
"resolved_size",
|
||||
"resolved_sizes",
|
||||
"overlaps",
|
||||
)
|
||||
measured = {key: issue[key] for key in measurement_keys if key in issue}
|
||||
return measured or {"violation_count": 1}
|
||||
|
||||
|
||||
def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"element_id": element["id"],
|
||||
"kind": element["kind"],
|
||||
"type": element["type"],
|
||||
"bbox": {key: element[key] for key in ("x", "y", "width", "height")},
|
||||
}
|
||||
|
||||
|
||||
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements: list[dict[str, Any]] = []
|
||||
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
start_x = extract_numeric_attribute(attrs, "startX")
|
||||
start_y = extract_numeric_attribute(attrs, "startY")
|
||||
end_x = extract_numeric_attribute(attrs, "endX")
|
||||
end_y = extract_numeric_attribute(attrs, "endY")
|
||||
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
||||
continue
|
||||
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
||||
"kind": "line",
|
||||
"type": "line",
|
||||
"x": min(start_x, end_x),
|
||||
"y": min(start_y, end_y),
|
||||
"width": abs(end_x - start_x),
|
||||
"height": abs(end_y - start_y),
|
||||
"rotation": 0,
|
||||
"alpha": line_alpha if line_alpha is not None else 1,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
return elements
|
||||
|
||||
|
||||
def normalize_issue(
|
||||
issue: dict[str, Any],
|
||||
slide_number: int | None,
|
||||
elements_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
normalized = dict(issue)
|
||||
if normalized.get("level") == "info":
|
||||
normalized["level"] = "warning"
|
||||
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
|
||||
normalized["schema_version"] = "2.0"
|
||||
normalized["element_ids"] = element_ids
|
||||
normalized["target"] = {
|
||||
**({"slide_number": slide_number} if slide_number is not None else {}),
|
||||
**normalized.get("target", {}),
|
||||
}
|
||||
normalized["rule"] = issue_rule(normalized)
|
||||
normalized["measurement"] = issue_measurement(normalized, elements_by_id)
|
||||
normalized["related_objects"] = [
|
||||
related_object(elements_by_id[element_id])
|
||||
for element_id in element_ids
|
||||
if element_id in elements_by_id
|
||||
]
|
||||
if normalized["code"] == "sparse_container_content":
|
||||
ratio = normalized["measurement"]["content_coverage_ratio"]
|
||||
threshold = normalized["rule"]["threshold"]
|
||||
container_id = normalized["target"].get("container_id", "unknown")
|
||||
normalized.setdefault(
|
||||
"message",
|
||||
f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
|
||||
)
|
||||
normalized.setdefault(
|
||||
"hint",
|
||||
"Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
|
||||
)
|
||||
elif normalized["code"] == "sparse_slide_content":
|
||||
ratio = normalized["measurement"]["content_coverage_ratio"]
|
||||
threshold = normalized["rule"]["threshold"]
|
||||
normalized.setdefault(
|
||||
"message",
|
||||
f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
|
||||
)
|
||||
normalized.setdefault(
|
||||
"hint",
|
||||
"Review the rendered screenshot to decide whether the page is intentionally sparse.",
|
||||
)
|
||||
else:
|
||||
normalized.setdefault("message", normalized["code"].replace("_", " "))
|
||||
normalized.setdefault(
|
||||
"hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
|
||||
if errors:
|
||||
return "blocked"
|
||||
if warnings:
|
||||
return "needs_screenshot_review"
|
||||
return "passed"
|
||||
|
||||
|
||||
def build_result(
|
||||
source_path: str | None,
|
||||
slide_size: dict[str, int | float],
|
||||
top_level_issues: list[dict[str, Any]],
|
||||
slides: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
|
||||
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
|
||||
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
|
||||
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
|
||||
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
|
||||
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
|
||||
status = slide_status(all_errors, all_warnings)
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": "2.0",
|
||||
"tool": "xml_text_overlap_lint",
|
||||
"file": source_path,
|
||||
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
||||
"slide_size": slide_size,
|
||||
"summary": {
|
||||
"slide_count": len(slides),
|
||||
"error_count": error_count,
|
||||
"warning_count": warning_count,
|
||||
"info_count": info_count,
|
||||
"status": status,
|
||||
"release_ready": error_count == 0,
|
||||
"screenshot_review_required": warning_count > 0,
|
||||
},
|
||||
"document": {
|
||||
"errors": document_errors,
|
||||
"warnings": document_warnings,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
@@ -1394,6 +2068,107 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
return result
|
||||
|
||||
|
||||
def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
root, xml_error = parse_xml_root(xml)
|
||||
if xml_error:
|
||||
issue = normalize_issue(xml_error, None, {})
|
||||
return build_result(
|
||||
source_path,
|
||||
{"width": 960, "height": 540},
|
||||
[issue],
|
||||
[],
|
||||
)
|
||||
if root is None:
|
||||
raise AssertionError("parse_xml_root must return a root or error")
|
||||
|
||||
namespace_issues = validate_sml_tag_prefixes(xml)
|
||||
sxsd_issues = validate_sxsd_tag_attributes(root)
|
||||
iconpark_issues = validate_iconpark_icon_types(root)
|
||||
top_level_issues = [
|
||||
normalize_issue(issue, None, {})
|
||||
for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
|
||||
]
|
||||
if any(issue["level"] == "error" for issue in top_level_issues):
|
||||
return build_result(
|
||||
source_path,
|
||||
{"width": 960, "height": 540},
|
||||
top_level_issues,
|
||||
[],
|
||||
)
|
||||
|
||||
presentation = parse_presentation(xml)
|
||||
slides: list[dict[str, Any]] = []
|
||||
for index, slide_xml in enumerate(presentation["slides"]):
|
||||
slide_number = index + 1
|
||||
geometry = lint_slide(
|
||||
slide_xml,
|
||||
slide_number,
|
||||
presentation["width"],
|
||||
presentation["height"],
|
||||
)
|
||||
density_elements = extract_density_elements(slide_xml)
|
||||
extra_elements = [
|
||||
element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
|
||||
]
|
||||
elements_by_id = {
|
||||
element["id"]: element for element in [*density_elements, *extra_elements]
|
||||
}
|
||||
# geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
|
||||
# decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
|
||||
# with whatever actually triggered the issue, instead of density_elements' separate re-parse.
|
||||
elements_by_id.update({element["id"]: element for element in geometry["elements"]})
|
||||
extra_overflow_issues = detect_elements_out_of_canvas(
|
||||
extra_elements,
|
||||
presentation["width"],
|
||||
presentation["height"],
|
||||
)
|
||||
raw_issues = [
|
||||
*geometry["issues"],
|
||||
*extra_overflow_issues,
|
||||
*detect_blank_slide(
|
||||
density_elements,
|
||||
slide_number,
|
||||
presentation["width"],
|
||||
presentation["height"],
|
||||
),
|
||||
*detect_sparse_container_content(
|
||||
density_elements,
|
||||
slide_number,
|
||||
presentation["width"],
|
||||
presentation["height"],
|
||||
),
|
||||
*detect_sparse_slide_content(
|
||||
density_elements,
|
||||
slide_number,
|
||||
presentation["width"],
|
||||
presentation["height"],
|
||||
),
|
||||
]
|
||||
issues = [
|
||||
normalize_issue(issue, slide_number, elements_by_id)
|
||||
for issue in raw_issues
|
||||
]
|
||||
errors = [issue for issue in issues if issue["level"] == "error"]
|
||||
warnings = [issue for issue in issues if issue["level"] == "warning"]
|
||||
slides.append(
|
||||
{
|
||||
"slide_number": slide_number,
|
||||
"status": slide_status(errors, warnings),
|
||||
"element_count": len(elements_by_id),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"issues": issues,
|
||||
}
|
||||
)
|
||||
|
||||
return build_result(
|
||||
source_path,
|
||||
{"width": presentation["width"], "height": presentation["height"]},
|
||||
top_level_issues,
|
||||
slides,
|
||||
)
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
|
||||
|
||||
@@ -1416,6 +2191,6 @@ def run_cli(argv: list[str] | None = None) -> None:
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
run_cli()
|
||||
except XmlTextOverlapLintError as error:
|
||||
except XmlLayoutLintError as error:
|
||||
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
|
||||
@@ -12,8 +12,8 @@ from pathlib import Path
|
||||
import xml_text_overlap_lint
|
||||
|
||||
|
||||
class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None:
|
||||
class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
def assertNoXmlTextOverlapLintErrors(self, result: dict, sample_name: str) -> None:
|
||||
issue_summaries = []
|
||||
for slide in result.get("slides", []):
|
||||
for issue in slide.get("issues", []):
|
||||
@@ -28,11 +28,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
0,
|
||||
f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
|
||||
)
|
||||
self.assertEqual(
|
||||
result["summary"]["warning_count"],
|
||||
0,
|
||||
f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
|
||||
)
|
||||
|
||||
def test_cli_suggests_input_flag_for_positional_argument(self) -> None:
|
||||
script_path = Path(xml_text_overlap_lint.__file__).resolve()
|
||||
@@ -49,7 +44,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(completed.stdout, "")
|
||||
self.assertEqual(
|
||||
completed.stderr,
|
||||
f"xml-text-overlap-lint error: unexpected argument: {input_path},need --input\n",
|
||||
f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n",
|
||||
)
|
||||
|
||||
def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
|
||||
@@ -101,7 +96,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
sample_xml,
|
||||
sample_name,
|
||||
)
|
||||
self.assertNoXmlTextOverlapLintIssues(result, sample_name)
|
||||
self.assertNoXmlTextOverlapLintErrors(result, sample_name)
|
||||
|
||||
def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -221,7 +216,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
|
||||
def test_lint_xml_single_slide_reports_out_of_canvas_and_blank_slide_errors(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
@@ -235,8 +230,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
|
||||
self.assertEqual(result["summary"]["slide_count"], 1)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(
|
||||
[issue["code"] for issue in result["slides"][0]["errors"]],
|
||||
["shape_out_of_canvas", "blank_slide"],
|
||||
)
|
||||
|
||||
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -618,8 +616,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["code"], "bbox_overlap")
|
||||
self.assertEqual(issue["elements"], ["source", "target"])
|
||||
self.assertGreater(issue["measurement"]["intersection_area"], 0)
|
||||
self.assertIsNotNone(issue.get("hint"))
|
||||
|
||||
def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -752,7 +753,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
"第一行\n第二行\n第三行",
|
||||
)
|
||||
|
||||
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
|
||||
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -770,8 +771,9 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
|
||||
|
||||
def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
|
||||
elements = xml_text_overlap_lint.extract_elements(
|
||||
@@ -805,7 +807,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(elements[1]["fontSize"], 28)
|
||||
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
|
||||
|
||||
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
|
||||
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -817,9 +819,10 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
|
||||
|
||||
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
|
||||
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -832,9 +835,13 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(
|
||||
[issue["code"] for issue in result["slides"][0]["errors"]],
|
||||
["img_out_of_canvas", "img_out_of_canvas"],
|
||||
)
|
||||
|
||||
def test_lint_xml_allows_full_bleed_images(self) -> None:
|
||||
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
@@ -846,7 +853,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
|
||||
|
||||
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
@@ -863,15 +871,36 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(result["summary"]["error_count"], 3)
|
||||
self.assertEqual(
|
||||
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
|
||||
[
|
||||
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
|
||||
("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
|
||||
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content fontSize="18"><p>Visible content</p></content>
|
||||
</shape>
|
||||
<line id="connector" startX="80" startY="120" endX="980" endY="120"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["errors"][0]
|
||||
self.assertEqual(issue["code"], "line_out_of_canvas")
|
||||
self.assertEqual(issue["element_ids"], ["connector"])
|
||||
self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
|
||||
self.assertEqual(issue["related_objects"][0]["kind"], "line")
|
||||
|
||||
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
@@ -893,6 +922,27 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
|
||||
|
||||
def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
|
||||
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 2)
|
||||
self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
|
||||
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
|
||||
self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
|
||||
self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
|
||||
|
||||
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
@@ -1057,9 +1107,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 2)
|
||||
self.assertEqual(issues_by_dimension["width"]["level"], "info")
|
||||
self.assertEqual(result["summary"]["warning_count"], 2)
|
||||
self.assertEqual(issues_by_dimension["width"]["level"], "warning")
|
||||
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
|
||||
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
|
||||
@@ -1084,7 +1133,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], 0)
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
|
||||
@@ -1153,7 +1201,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
}
|
||||
script_path = Path(xml_text_overlap_lint.__file__).resolve()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
for name, (table_xml, expected_info_count) in cases.items():
|
||||
for name, (table_xml, expected_warning_count) in cases.items():
|
||||
with self.subTest(case=name):
|
||||
input_path = Path(temp_dir) / f"{name}.xml"
|
||||
input_path.write_text(
|
||||
@@ -1173,10 +1221,9 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
result = json.loads(completed.stdout)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["info_count"], expected_info_count)
|
||||
self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
|
||||
self.assertTrue(
|
||||
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
|
||||
all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
|
||||
result["slides"][0]["issues"],
|
||||
)
|
||||
|
||||
@@ -1209,7 +1256,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
|
||||
|
||||
|
||||
def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None:
|
||||
def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
|
||||
@@ -1221,9 +1268,710 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
"""
|
||||
)
|
||||
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
|
||||
self.assertEqual(issue["level"], "info")
|
||||
self.assertEqual(issue["level"], "warning")
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
|
||||
class XmlTextOverlapLintDensityTest(unittest.TestCase):
|
||||
def test_lint_xml_blocks_blank_slide(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide id="content-slide">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="60" topLeftY="60" width="400" height="50">
|
||||
<content fontSize="28"><p>Investment report</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
<slide id="blank-slide">
|
||||
<style><fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></style>
|
||||
<data/>
|
||||
<note><content/></note>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["slide_count"], 2)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(result["summary"]["status"], "blocked")
|
||||
self.assertFalse(result["summary"]["release_ready"])
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
self.assertEqual(result["slides"][1]["element_count"], 0)
|
||||
issue = result["slides"][1]["errors"][0]
|
||||
self.assertEqual(issue["level"], "error")
|
||||
self.assertEqual(issue["code"], "blank_slide")
|
||||
self.assertEqual(issue["element_ids"], [])
|
||||
self.assertEqual(issue["rule"]["id"], "blank_slide")
|
||||
self.assertEqual(issue["measurement"]["visible_element_count"], 0)
|
||||
self.assertEqual(issue["related_objects"], [])
|
||||
|
||||
def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<img id="ghost" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
issue = result["slides"][0]["errors"][0]
|
||||
self.assertEqual(issue["code"], "blank_slide")
|
||||
|
||||
def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="trend-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
|
||||
<shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
|
||||
<content fontSize="15"><p>Core trends</p></content>
|
||||
</shape>
|
||||
<shape id="trend-copy" type="text" topLeftX="515" topLeftY="177" width="380" height="315">
|
||||
<content fontSize="12"><p>First point</p><p>Second point</p><p>Third point</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["code"], "sparse_container_content")
|
||||
self.assertEqual(issue["target"]["container_id"], "trend-card")
|
||||
self.assertEqual(issue["target"], {
|
||||
"slide_number": 1,
|
||||
"container_id": "trend-card",
|
||||
"container_type": "rect",
|
||||
"bbox": {"x": 500, "y": 135, "width": 410, "height": 370},
|
||||
})
|
||||
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15)
|
||||
self.assertEqual(issue["rule"], {
|
||||
"name": "large_container_visible_content_coverage",
|
||||
"threshold": 0.15,
|
||||
"comparison": "content_coverage_ratio < threshold",
|
||||
"id": "sparse_container_content",
|
||||
})
|
||||
self.assertEqual(issue["measurement"]["container_area"], 151700)
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
|
||||
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
|
||||
self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
|
||||
self.assertEqual(
|
||||
[obj["element_id"] for obj in issue["related_objects"]],
|
||||
["trend-card", "trend-title", "trend-copy"],
|
||||
)
|
||||
self.assertEqual(result["slides"][0]["status"], "needs_screenshot_review")
|
||||
self.assertEqual(result["slides"][0]["warnings"], result["slides"][0]["issues"])
|
||||
|
||||
def test_lint_xml_warns_for_sparse_short_cards(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card-1" type="rect" topLeftX="60" topLeftY="180" width="400" height="105"/>
|
||||
<shape id="text-1" type="text" topLeftX="80" topLeftY="220" width="360" height="30">
|
||||
<content fontSize="14"><p>期待认识大家</p></content>
|
||||
</shape>
|
||||
<shape id="card-2" type="rect" topLeftX="490" topLeftY="180" width="400" height="105"/>
|
||||
<shape id="text-2" type="text" topLeftX="510" topLeftY="220" width="360" height="30">
|
||||
<content fontSize="14"><p>化学一起讨论</p></content>
|
||||
</shape>
|
||||
<shape id="card-3" type="rect" topLeftX="60" topLeftY="310" width="400" height="105"/>
|
||||
<shape id="text-3" type="text" topLeftX="80" topLeftY="350" width="360" height="30">
|
||||
<content fontSize="14"><p>吉他随时交流</p></content>
|
||||
</shape>
|
||||
<shape id="card-4" type="rect" topLeftX="490" topLeftY="310" width="400" height="105"/>
|
||||
<shape id="text-4" type="text" topLeftX="510" topLeftY="350" width="360" height="30">
|
||||
<content fontSize="14"><p>共度美好四年</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
container_issues = [
|
||||
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
|
||||
]
|
||||
self.assertEqual(
|
||||
[issue["target"]["container_id"] for issue in container_issues],
|
||||
["card-1", "card-2", "card-3", "card-4"],
|
||||
)
|
||||
self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues))
|
||||
self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues))
|
||||
self.assertEqual(
|
||||
[issue["code"] for issue in result["slides"][0]["issues"]],
|
||||
[
|
||||
"sparse_container_content",
|
||||
"sparse_container_content",
|
||||
"sparse_container_content",
|
||||
"sparse_container_content",
|
||||
"sparse_slide_content",
|
||||
],
|
||||
)
|
||||
|
||||
def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
|
||||
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
|
||||
<content fontSize="14"><p>One short line</p></content>
|
||||
</shape>
|
||||
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
|
||||
<content fontSize="14"><p>Another line</p></content>
|
||||
</shape>
|
||||
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
|
||||
<content fontSize="14"><p>Third line</p></content>
|
||||
</shape>
|
||||
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
|
||||
<content fontSize="14"><p>Fourth line</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"]
|
||||
self.assertEqual(len(issues), 1)
|
||||
issue = issues[0]
|
||||
self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540})
|
||||
self.assertEqual(issue["rule"]["threshold"], 0.035)
|
||||
self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035)
|
||||
self.assertEqual(issue["measurement"]["content_element_count"], 4)
|
||||
self.assertNotIn("background", issue["elements"])
|
||||
|
||||
def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="summary-bar" type="rect" topLeftX="52" topLeftY="82" width="856" height="105"/>
|
||||
<shape id="summary" type="text" topLeftX="72" topLeftY="115" width="816" height="30">
|
||||
<content fontSize="14"><p>One concise summary</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="18">
|
||||
<p>被吊物</p>
|
||||
<p><span fontSize="36">32.0 t</span></p>
|
||||
<p>钢结构模块</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="18">
|
||||
<p>被吊物</p>
|
||||
<p>32.0 t</p>
|
||||
<p>钢结构模块</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
|
||||
self.assertEqual(issue["measurement"]["content_element_count"], 1)
|
||||
self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0)
|
||||
|
||||
def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="sparse-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["target"]["container_id"], "sparse-card")
|
||||
self.assertGreater(issue["measurement"]["visible_content_area"], 0)
|
||||
self.assertEqual(issue["measurement"]["content_element_count"], 1)
|
||||
self.assertEqual(issue["elements"], ["sparse-card"])
|
||||
|
||||
def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None:
|
||||
self_only = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
with_overlapping_child = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
<shape id="child" type="text" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p>A</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self_issue = self_only["slides"][0]["issues"][0]
|
||||
mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
|
||||
self.assertEqual(
|
||||
mixed_issue["measurement"]["visible_content_area"],
|
||||
self_issue["measurement"]["visible_content_area"],
|
||||
)
|
||||
self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2)
|
||||
|
||||
def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None:
|
||||
elements = xml_text_overlap_lint.extract_density_elements(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
|
||||
<content fontSize="12"><p><span fontSize="36">32.0 t</span></p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(elements[0]["fontSize"], 36)
|
||||
|
||||
def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None:
|
||||
elements = xml_text_overlap_lint.extract_density_elements(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
|
||||
<shape id="title" type="text" topLeftX="80" topLeftY="160" width="180" height="30">
|
||||
<content fontSize="18"><p>Following title</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(elements[0]["text"], "")
|
||||
self.assertEqual(elements[1]["text"], "Following title")
|
||||
|
||||
def test_lint_xml_allows_container_with_large_visual_child(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
|
||||
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
|
||||
<content fontSize="20"><p>Section title</p></content>
|
||||
</shape>
|
||||
<shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
|
||||
<chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = next(
|
||||
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
|
||||
)
|
||||
self.assertEqual(issue["target"]["container_id"], "chart-card")
|
||||
|
||||
def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="letter-placeholder" type="rect" topLeftX="520" topLeftY="180" width="200" height="200"/>
|
||||
<shape id="letter" type="text" topLeftX="540" topLeftY="250" width="160" height="70">
|
||||
<content fontSize="46"><p>Z</p></content>
|
||||
</shape>
|
||||
<shape id="empty-placeholder" type="rect" topLeftX="744" topLeftY="180" width="144" height="200"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(
|
||||
[issue["target"]["container_id"] for issue in issues],
|
||||
["letter-placeholder", "empty-placeholder"],
|
||||
)
|
||||
self.assertEqual(issues[1]["measurement"]["content_element_count"], 0)
|
||||
|
||||
def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="70" topLeftY="184" width="260" height="288"/>
|
||||
<shape id="title" type="text" topLeftX="90" topLeftY="215" width="220" height="30">
|
||||
<content fontSize="18"><p>梦境与现实</p></content>
|
||||
</shape>
|
||||
<shape id="copy" type="text" topLeftX="90" topLeftY="330" width="220" height="70">
|
||||
<content fontSize="13"><p>边界溶解,逻辑失效。观众被拽入潜意识的迷宫。</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["target"]["container_id"], "card")
|
||||
self.assertEqual(issue["rule"]["threshold"], 0.15)
|
||||
|
||||
def test_lint_xml_allows_image_overlay_rect(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<img id="hero" topLeftX="560" topLeftY="0" width="400" height="540"/>
|
||||
<shape id="tint" type="rect" topLeftX="560" topLeftY="0" width="400" height="540"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
|
||||
<content fontSize="20"><p>Section title</p></content>
|
||||
</shape>
|
||||
<shape id="card" type="rect" topLeftX="330" topLeftY="120" width="300" height="300"/>
|
||||
<img id="ghost-overlay" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = next(
|
||||
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
|
||||
)
|
||||
self.assertEqual(issue["target"]["container_id"], "card")
|
||||
|
||||
def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="panel" type="rect" topLeftX="600" topLeftY="0" width="360" height="540"/>
|
||||
<shape id="decoration" type="rect" topLeftX="660" topLeftY="150" width="240" height="240"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_counts_icons_as_visible_content(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
|
||||
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180">
|
||||
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
|
||||
</icon>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_does_not_count_transparent_icon_as_visible_content(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
|
||||
<content fontSize="20"><p>Section title</p></content>
|
||||
</shape>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
|
||||
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180" alpha="0">
|
||||
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
|
||||
</icon>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = next(
|
||||
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
|
||||
)
|
||||
self.assertEqual(issue["target"]["container_id"], "card")
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0)
|
||||
|
||||
def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
|
||||
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="70" height="70">
|
||||
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
|
||||
</icon>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["target"]["container_id"], "card")
|
||||
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122)
|
||||
self.assertEqual(issue["rule"]["threshold"], 0.15)
|
||||
|
||||
def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
|
||||
<icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="100" height="100">
|
||||
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
|
||||
</icon>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
|
||||
<shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
|
||||
<content fontSize="12.4"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["issues"], [])
|
||||
|
||||
def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<line id="l1" startX="100" startY="100" endX="800" endY="100"/>
|
||||
<line id="l2" startX="100" startY="200" endX="800" endY="200"/>
|
||||
<line id="l3" startX="100" startY="300" endX="800" endY="300"/>
|
||||
<line id="l4" startX="100" startY="400" endX="800" endY="400"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertNotIn("blank_slide", codes)
|
||||
|
||||
def test_lint_xml_reports_bbox_overlap_measurement_from_decision_time_visual_bbox(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="left" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content fontSize="14"><p>overlap text <span fontSize="96">big</span></p></content>
|
||||
</shape>
|
||||
<shape id="right" type="text" topLeftX="80" topLeftY="80" width="300" height="80">
|
||||
<content fontSize="14"><p>other overlap text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = result["slides"][0]["issues"][0]
|
||||
self.assertEqual(issue["code"], "bbox_overlap")
|
||||
# Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
|
||||
# from extract_elements), not the fontSize=96 max-descendant value that
|
||||
# extract_density_elements computes for the same "left" element id.
|
||||
self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
|
||||
self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
|
||||
self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
|
||||
|
||||
def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
|
||||
card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
|
||||
card_b = {"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100}
|
||||
card_c = {"kind": "shape", "type": "rect", "x": 0, "y": 200, "width": 300, "height": 100}
|
||||
|
||||
self.assertFalse(
|
||||
xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b])
|
||||
)
|
||||
self.assertTrue(
|
||||
xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b, card_c])
|
||||
)
|
||||
|
||||
def test_lint_xml_reports_schema_version_2_for_sparse_issues(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
issue = next(
|
||||
issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
|
||||
)
|
||||
self.assertEqual(issue["schema_version"], "2.0")
|
||||
|
||||
def test_lint_xml_does_not_report_blank_slide_for_textless_decorative_shapes(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="deco1" type="ellipse" topLeftX="60" topLeftY="60" width="300" height="300">
|
||||
<fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
|
||||
</shape>
|
||||
<shape id="deco2" type="triangle" topLeftX="500" topLeftY="200" width="200" height="200">
|
||||
<fill><fillColor color="rgba(220, 38, 38, 1)"/></fill>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertNotIn("blank_slide", codes)
|
||||
|
||||
def test_lint_xml_still_warns_for_sparse_slide_content_despite_full_bleed_background(self) -> None:
|
||||
# A plain textless shape now counts as "not blank" (see the test above), but a
|
||||
# full-bleed background rect must still NOT count toward sparse_slide_content's
|
||||
# meaningful-content coverage ratio -- otherwise every slide with a background would
|
||||
# trivially "pass" that density check.
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
|
||||
<shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
|
||||
<content fontSize="14"><p>One short line</p></content>
|
||||
</shape>
|
||||
<shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
|
||||
<content fontSize="14"><p>Another line</p></content>
|
||||
</shape>
|
||||
<shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
|
||||
<content fontSize="14"><p>Third line</p></content>
|
||||
</shape>
|
||||
<shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
|
||||
<content fontSize="14"><p>Fourth line</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertIn("sparse_slide_content", codes)
|
||||
|
||||
def test_lint_xml_accepts_whitespace_around_attribute_equals_sign(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="visible" type="text" topLeftX = "80" topLeftY = "80" width = "300" height = "60">
|
||||
<content><p>hello</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result["slides"][0]["element_count"], 1)
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertNotIn("blank_slide", codes)
|
||||
|
||||
def test_lint_xml_reports_blank_slide_for_full_canvas_background_only(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540">
|
||||
<fill><fillColor color="rgba(240, 235, 220, 1)"/></fill>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
|
||||
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
|
||||
self.assertIn("blank_slide", codes)
|
||||
|
||||
def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None:
|
||||
visible_card = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
|
||||
ghost_1 = {
|
||||
"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100, "alpha": 0,
|
||||
}
|
||||
ghost_2 = {
|
||||
"kind": "shape", "type": "rect", "x": 800, "y": 0, "width": 300, "height": 100, "alpha": 0,
|
||||
}
|
||||
|
||||
self.assertFalse(
|
||||
xml_text_overlap_lint.has_similar_short_card_peer(
|
||||
visible_card, [visible_card, ghost_1, ghost_2]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -38,6 +38,13 @@ metadata:
|
||||
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识(GUID),不是客户端展示的任务编号(例如 `t104121` / `suite_entity_num`)。
|
||||
> 对于 Feishu 的任务 applink(例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
|
||||
|
||||
> **从任务清单定位并修改任务的最短路径**:
|
||||
> 1. 已知任务清单 GUID 时直接使用,不要先搜索;已知任务清单 applink 时,取 URL query 中的 `guid` 作为 `tasklist_guid`。
|
||||
> 2. 只有清单名称或关键词、没有 GUID/applink 时,才调用一次 `+tasklist-search` 解析目标清单。
|
||||
> 3. 按原生 API 规则先执行 `lark-cli schema task.tasklists.tasks`,再执行 `lark-cli task tasklists tasks --params '{"tasklist_guid":"<tasklist_guid>"}' --as user`。
|
||||
> 4. 从清单任务结果中取任务的 `guid`,直接传给 `+update` 或 `+complete`;禁止传客户端展示编号(例如 `t104121`)。这两个 shortcut 也可直接接收包含 `guid=` 的任务 applink。
|
||||
> 5. `+update` 返回 `updated_fields` 和每个任务的服务端 `confirmed` 字段;`+complete` 返回 `status`、`completed_at`、`already_completed`。这些字段已确认目标状态时,不要例行追加 `tasks get`;仅在服务端未返回所需字段或用户明确要求完整复核时再查询详情。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-task-create.md) | create a task |
|
||||
|
||||
@@ -9,19 +9,23 @@ Mark a task as completed.
|
||||
```bash
|
||||
# Complete a task
|
||||
lark-cli task +complete --task-id "<task_guid>"
|
||||
|
||||
# A task applink is accepted directly; the CLI extracts its guid query value
|
||||
lark-cli task +complete --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--task-id <guid>` | Yes | The task GUID to complete. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
|
||||
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the task to complete.
|
||||
2. Execute the command.
|
||||
3. Report success.
|
||||
3. Read `data.status`, `data.completed_at`, and `data.already_completed` from the result. `already_completed: true` means the shortcut observed an already-completed task and skipped the PATCH.
|
||||
4. Do not routinely call `task tasks get` when the result already reports `status: done` and a non-zero `completed_at`. Query details only if confirmation fields are absent or the user explicitly asks for a full verification.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
@@ -13,6 +13,9 @@ lark-cli task +update --task-id "<task_guid>" --summary "New Summary"
|
||||
# Update multiple tasks' due dates
|
||||
lark-cli task +update --task-id "<task_guid>,<another_task_guid>" --due "+2d"
|
||||
|
||||
# A task applink is accepted directly; the CLI extracts its guid query value
|
||||
lark-cli task +update --task-id "https://applink.larksuite.com/client/todo/task?guid=<task_guid>" --summary "New Summary"
|
||||
|
||||
# Update with JSON data
|
||||
lark-cli task +update --task-id "<task_guid>" --data '{"description": "New description"}'
|
||||
```
|
||||
@@ -21,7 +24,7 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `--task-id <guid>` | Yes | The task GUID to update. Comma-separated task GUIDs are supported for multiple tasks. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
|
||||
| `--task-id <guid-or-applink>` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Comma-separated GUIDs/applinks are supported for multiple tasks. Display task IDs such as `t104121` / `suite_entity_num` are rejected. |
|
||||
| `--summary <text>` | No | New summary/title for the task. |
|
||||
| `--description <text>` | No | New description for the task. |
|
||||
| `--due <time>` | No | New due date (supports relative time). |
|
||||
@@ -31,7 +34,8 @@ lark-cli task +update --task-id "<task_guid>" --data '{"description": "New descr
|
||||
|
||||
1. Confirm with the user the tasks to update and the fields.
|
||||
2. Execute `lark-cli task +update --task-id "..." ...`
|
||||
3. Report the successful updates.
|
||||
3. Read `data.updated_fields` and `data.tasks[].confirmed` from the result and report only the fields confirmed by the server.
|
||||
4. Do not routinely call `task tasks get` after the update when `confirmed` already contains the required state. Query details only if a required field is absent or the user explicitly asks for a full verification.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||
|
||||
62
tests/cli_e2e/apps/apps_file_upload_dryrun_test.go
Normal file
62
tests/cli_e2e/apps/apps_file_upload_dryrun_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
absolutePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||
require.NoError(t, os.WriteFile(absolutePath, []byte("dry-run-input"), 0o600))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+file-upload",
|
||||
"--app-id", "app_x",
|
||||
"--file", absolutePath,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "report.pdf", clie2e.DryRunGet(result.Stdout, "api.0.body.file_name").String())
|
||||
}
|
||||
|
||||
func TestAppsFileUploadDryRun_RejectsMissingHostPath(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "report.pdf")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"apps", "+file-upload",
|
||||
"--app-id", "app_x",
|
||||
"--file", missingAbsolutePath,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
||||
require.Equal(t, "--file", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
||||
}
|
||||
96
tests/cli_e2e/apps/apps_file_upload_live_test.go
Normal file
96
tests/cli_e2e/apps/apps_file_upload_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestAppsFileUploadLiveWorkflow(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("LARKSUITE_CLI_CONFIG_DIR")) == "" {
|
||||
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated live-test config")
|
||||
}
|
||||
appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_FILE_APP_ID"))
|
||||
if appID == "" {
|
||||
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_FILE_APP_ID to a dedicated app for upload/delete testing")
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("lark-cli-host-path-e2e-%d.txt", time.Now().UnixNano())
|
||||
absolutePath := filepath.Join(t.TempDir(), fileName)
|
||||
content := []byte("host-path-live-e2e")
|
||||
require.NoError(t, os.WriteFile(absolutePath, content, 0o600))
|
||||
|
||||
remotePath := ""
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
if remotePath == "" {
|
||||
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-list", "--app-id", appID, "--name", fileName},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
if listErr != nil || listResult.ExitCode != 0 {
|
||||
clie2e.ReportCleanupFailure(t, "find uploaded file "+fileName, listResult, listErr)
|
||||
return
|
||||
}
|
||||
for _, item := range gjson.Get(listResult.Stdout, "data.items").Array() {
|
||||
if item.Get("file_name").String() == fileName {
|
||||
remotePath = item.Get("path").String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if remotePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-delete", "--app-id", appID, "--path", remotePath},
|
||||
DefaultAs: "user",
|
||||
Yes: true,
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, "delete uploaded file "+remotePath, deleteResult, deleteErr)
|
||||
if deleteErr == nil && deleteResult != nil && gjson.Get(deleteResult.Stdout, "data.results.0.status").String() != "ok" {
|
||||
t.Errorf("cleanup delete did not report success: %s", deleteResult.Stdout)
|
||||
}
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-upload", "--app-id", appID, "--file", absolutePath},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
uploadResult.AssertExitCode(t, 0)
|
||||
uploadResult.AssertStdoutStatus(t, true)
|
||||
remotePath = gjson.Get(uploadResult.Stdout, "data.path").String()
|
||||
require.NotEmpty(t, remotePath, "stdout:\n%s", uploadResult.Stdout)
|
||||
assert.Equal(t, fileName, gjson.Get(uploadResult.Stdout, "data.file_name").String(), "stdout:\n%s", uploadResult.Stdout)
|
||||
|
||||
getResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+file-get", "--app-id", appID, "--path", remotePath},
|
||||
DefaultAs: "user",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
return result == nil || result.ExitCode != 0 || gjson.Get(result.Stdout, "data.path").String() != remotePath
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, int64(len(content)), gjson.Get(getResult.Stdout, "data.size_bytes").Int(), "stdout:\n%s", getResult.Stdout)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# Apps CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
|
||||
- Selected command coverage: 100% (18/18)
|
||||
- API dry-run coverage: 100% (16/16 API-backed commands)
|
||||
- Denominator: 19 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
|
||||
- Selected command coverage: 100% (19/19)
|
||||
- API dry-run coverage: 100% (17/17 API-backed commands)
|
||||
- Local E2E coverage: 100% (2/2 local-only commands)
|
||||
- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`.
|
||||
- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup.
|
||||
|
||||
## Summary
|
||||
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
|
||||
@@ -14,6 +14,8 @@
|
||||
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
|
||||
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
|
||||
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
|
||||
- `TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath`: dry-run validates an absolute local file and derives its basename without uploading it; a missing source path is rejected before preview.
|
||||
- `TestAppsFileUploadLiveWorkflow`: fixture-gated absolute-path upload, `+file-get` readback, and `+file-delete` cleanup in a dedicated app.
|
||||
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
|
||||
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
|
||||
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
|
||||
@@ -23,7 +25,7 @@
|
||||
- `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers.
|
||||
- `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added.
|
||||
|
||||
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members.
|
||||
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -35,6 +37,7 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps
|
||||
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
|
||||
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
|
||||
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
|
||||
| ✓ | apps +file-upload | shortcut | apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_AcceptsAbsoluteHostPath; apps_file_upload_dryrun_test.go::TestAppsFileUploadDryRun_RejectsMissingHostPath; apps_file_upload_live_test.go::TestAppsFileUploadLiveWorkflow | `--app-id`, `--file` (absolute or relative local path) | live workflow uses `LARK_CLI_E2E_APPS_FILE_APP_ID`, reads metadata back, and deletes the uploaded file |
|
||||
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
|
||||
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
|
||||
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |
|
||||
|
||||
40
tests/cli_e2e/base/base_form_submit_dryrun_test.go
Normal file
40
tests/cli_e2e/base/base_form_submit_dryrun_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseFormSubmitDryRun(t *testing.T) {
|
||||
setBaseDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"base", "+form-submit",
|
||||
"--share-token", "shrXXXX",
|
||||
"--json", `{"fields":{"Rating":5}}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/submit")
|
||||
assert.Contains(t, output, `"share_token"`)
|
||||
assert.Contains(t, output, "shrXXXX")
|
||||
assert.Contains(t, output, `"method": "POST"`)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestSlidesPresentationAliasesDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
aliases := []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+xml-get",
|
||||
"--" + alias, "presAliasDryRun",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, "GET", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
|
||||
require.Equal(t,
|
||||
"/open-apis/slides_ai/v1/xml_presentations/presAliasDryRun",
|
||||
gjson.Get(result.Stdout, "data.api.0.url").String(),
|
||||
result.Stdout,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
49
tests/cli_e2e/slides/slides_screenshot_dryrun_test.go
Normal file
49
tests/cli_e2e/slides/slides_screenshot_dryrun_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSlidesScreenshotSlideIDCSVDryRunE2E pins the CSV multi-value parsing for
|
||||
// --slide-id through the built CLI: a single comma-separated flag value must
|
||||
// expand into the same slide_ids request body that repeating the flag would
|
||||
// produce.
|
||||
func TestSlidesScreenshotSlideIDCSVDryRunE2E(t *testing.T) {
|
||||
setSlidesDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"slides", "+screenshot",
|
||||
"--presentation", "presScreenshotDryRun",
|
||||
"--slide-id", "slide_1,slide_2",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, "POST", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
|
||||
require.Equal(t,
|
||||
"/open-apis/slides_ai/v1/xml_presentations/presScreenshotDryRun/slide_images",
|
||||
gjson.Get(result.Stdout, "data.api.0.url").String(),
|
||||
result.Stdout,
|
||||
)
|
||||
|
||||
slideIDs := gjson.Get(result.Stdout, "data.api.0.body.slide_ids").Array()
|
||||
require.Len(t, slideIDs, 2, result.Stdout)
|
||||
require.Equal(t, "slide_1", slideIDs[0].String(), result.Stdout)
|
||||
require.Equal(t, "slide_2", slideIDs[1].String(), result.Stdout)
|
||||
}
|
||||
99
tests/cli_e2e/task/task_id_handling_dryrun_test.go
Normal file
99
tests/cli_e2e/task/task_id_handling_dryrun_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestTaskIDHandlingDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "task_id_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_id_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
run := func(t *testing.T, args []string) *clie2e.Result {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
t.Run("GUID and applink produce equivalent update requests", func(t *testing.T) {
|
||||
guidResult := run(t, []string{
|
||||
"task", "+update", "--task-id", "task-guid-123", "--summary", "updated", "--dry-run",
|
||||
})
|
||||
guidResult.AssertExitCode(t, 0)
|
||||
applinkResult := run(t, []string{
|
||||
"task", "+update", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-123", "--summary", "updated", "--dry-run",
|
||||
})
|
||||
applinkResult.AssertExitCode(t, 0)
|
||||
|
||||
wantURL := "/open-apis/task/v2/tasks/task-guid-123"
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(guidResult.Stdout, "api.0.url").String())
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(applinkResult.Stdout, "api.0.url").String())
|
||||
require.Equal(t, clie2e.DryRunGet(guidResult.Stdout, "api.0.body").Raw, clie2e.DryRunGet(applinkResult.Stdout, "api.0.body").Raw)
|
||||
})
|
||||
|
||||
t.Run("multi-ID update previews every mutation", func(t *testing.T) {
|
||||
result := run(t, []string{
|
||||
"task", "+update",
|
||||
"--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/task?guid=task-guid-2",
|
||||
"--summary", "updated",
|
||||
"--dry-run",
|
||||
})
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
|
||||
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
|
||||
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-1", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
|
||||
require.Equal(t, "PATCH", clie2e.DryRunGet(result.Stdout, "api.1.method").String())
|
||||
require.Equal(t, "/open-apis/task/v2/tasks/task-guid-2", clie2e.DryRunGet(result.Stdout, "api.1.url").String())
|
||||
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.params").Raw, clie2e.DryRunGet(result.Stdout, "api.1.params").Raw)
|
||||
require.Equal(t, clie2e.DryRunGet(result.Stdout, "api.0.body").Raw, clie2e.DryRunGet(result.Stdout, "api.1.body").Raw)
|
||||
})
|
||||
|
||||
t.Run("GUID and applink produce equivalent completion requests", func(t *testing.T) {
|
||||
guidResult := run(t, []string{
|
||||
"task", "+complete", "--task-id", "task-guid-456", "--dry-run",
|
||||
})
|
||||
guidResult.AssertExitCode(t, 0)
|
||||
applinkResult := run(t, []string{
|
||||
"task", "+complete", "--task-id", "https://applink.larksuite.com/client/todo/task?guid=task-guid-456", "--dry-run",
|
||||
})
|
||||
applinkResult.AssertExitCode(t, 0)
|
||||
|
||||
wantURL := "/open-apis/task/v2/tasks/task-guid-456"
|
||||
for _, result := range []*clie2e.Result{guidResult, applinkResult} {
|
||||
require.Equal(t, int64(2), clie2e.DryRunGet(result.Stdout, "api.#").Int())
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.0.url").String())
|
||||
require.Equal(t, wantURL, clie2e.DryRunGet(result.Stdout, "api.1.url").String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, shortcut := range []string{"+update", "+complete"} {
|
||||
t.Run(shortcut+" rejects display numbers", func(t *testing.T) {
|
||||
args := []string{"task", shortcut, "--task-id", "t12345", "--dry-run"}
|
||||
if shortcut == "+update" {
|
||||
args = append(args, "--summary", "must not be written")
|
||||
}
|
||||
result := run(t, args)
|
||||
result.AssertExitCode(t, 2)
|
||||
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "guid=", "stderr:\n%s", result.Stderr)
|
||||
require.False(t, gjson.Get(result.Stdout, "data.api").Exists(), "invalid input must not emit a dry-run API request\nstdout:\n%s", result.Stdout)
|
||||
})
|
||||
}
|
||||
}
|
||||
70
tests/cli_e2e/task/task_id_handling_workflow_test.go
Normal file
70
tests/cli_e2e/task/task_id_handling_workflow_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestTaskIDHandlingWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
originalSummary := "lark-cli-e2e-task-id-original-" + suffix
|
||||
updatedSummary := "lark-cli-e2e-task-id-updated-" + suffix
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"summary": originalSummary,
|
||||
"description": "created by task ID handling workflow",
|
||||
},
|
||||
})
|
||||
taskApplink := "https://applink.larksuite.com/client/todo/task?guid=" + url.QueryEscape(taskGUID)
|
||||
|
||||
t.Run("update accepts task applink", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "+update", "--task-id", taskApplink, "--summary", updatedSummary},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.tasks.0.guid").String(), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.tasks.0.confirmed.summary").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("display number is rejected without modifying task", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "+update", "--task-id", "t12345", "--summary", "must-not-be-written-" + suffix},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
assert.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
assert.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
assert.Equal(t, "--task-id", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
|
||||
getResult, getErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"task", "tasks", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"task_guid": taskGUID},
|
||||
})
|
||||
require.NoError(t, getErr)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, updatedSummary, gjson.Get(getResult.Stdout, "data.task.summary").String(), "stdout:\n%s", getResult.Stdout)
|
||||
})
|
||||
}
|
||||
63
tests/cli_e2e/task/task_search_pagination_dryrun_test.go
Normal file
63
tests/cli_e2e/task/task_search_pagination_dryrun_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTask_SearchPaginationDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "task_search_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_search_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "tasks",
|
||||
command: "+search",
|
||||
url: "/open-apis/task/v2/tasks/search",
|
||||
},
|
||||
{
|
||||
name: "tasklists",
|
||||
command: "+tasklist-search",
|
||||
url: "/open-apis/task/v2/tasklists/search",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"task", tt.command,
|
||||
"--query", "pagination",
|
||||
"--page-token", "initial_pt",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
require.Equal(t, tt.url, clie2e.DryRunGet(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "initial_pt", clie2e.DryRunGet(out, "api.0.params.page_token").String(), out)
|
||||
require.Equal(t, "pagination", clie2e.DryRunGet(out, "api.0.body.query").String(), out)
|
||||
require.False(t, clie2e.DryRunGet(out, "api.0.body.page_token").Exists(), out)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user