Compare commits

...

9 Commits

Author SHA1 Message Date
songtianyi.theo
84c19b83f0 docs: require hero imagery for svg slide roles 2026-07-08 15:47:52 +08:00
songtianyi.theo
85c07a990e docs: align svglide image resource boundary 2026-07-08 15:42:33 +08:00
songtianyi.theo
a617d168c4 docs: move svglide plans out of skill references 2026-07-08 15:41:45 +08:00
songtianyi.theo
d7cd8e1b37 docs: harden create-svglide validation plan 2026-07-08 15:33:09 +08:00
songtianyi.theo
0e98163562 docs: align svg slides canvas to 960x540 2026-07-08 14:08:26 +08:00
songtianyi.theo
e3e5a0b723 docs: add svg slides local generation workflow 2026-07-08 13:58:27 +08:00
SunPeiYang996
9a6ba41684 feat: support whiteboard file inputs in docs XML (#1784)
* feat: support whiteboard file inputs in docs XML

* docs: prefer path syntax for whiteboard file inputs

* docs: keep lark-doc skill metadata unchanged

* fix: aggregate whiteboard path errors across markdown
2026-07-08 11:18:34 +08:00
bubbmon233
f495cbb166 feat(mail): add message modify and trash shortcuts (#1567) 2026-07-07 21:44:40 +08:00
Yuxuan Zhao
6f95c5eb22 e2e: harden CLI E2E retry, cleanup, and domain selection (#1709) 2026-07-07 19:41:11 +08:00
64 changed files with 9983 additions and 150 deletions

View File

@@ -263,13 +263,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Run dry-run E2E tests
env:
@@ -277,7 +283,28 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -292,15 +319,22 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -310,16 +344,24 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions."
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -14,11 +14,13 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -0,0 +1,10 @@
# Lark Slides Development Plans
本目录只保存开发阶段计划、评审记录和执行拆解,不属于 `lark-slides` skill 的运行时提示词或工具使用参考。
约束:
- 不要从 `skills/lark-slides/SKILL.md` 路由到本目录。
- 不要把本目录内容作为 agent 调用 `slides` 工具时的稳定协议或操作规范。
- 当计划中的结论沉淀为长期有效规则时,先拆成原子化、可验证的运行时说明,再放入 `skills/lark-slides/references/` 或其下的专题目录。
- 当计划仅用于阶段性开发判断时,继续留在本目录,避免污染工具提示词边界。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
exit 1
fi

54
scripts/domain-map.js Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

71
scripts/domain-map.json Normal file
View File

@@ -0,0 +1,71 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

224
scripts/e2e_domains.js Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,6 +4,7 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
return labelDomainsForPath(filePath)[0] || "";
}
async function detectNewShortcutDomain(files) {

View File

@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
trap 'rm -rf "$tmp"' EXIT
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
mkdir -p "$tmp"
git_init() {

View File

@@ -27,12 +27,17 @@ const (
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
whiteboardTag = "whiteboard"
whiteboardTypeAttr = "type"
whiteboardPathAttr = "path"
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
)
type html5BlockReferenceEntry struct {
@@ -58,6 +63,11 @@ type html5BlockStartTag struct {
SelfClosing bool
}
type whiteboardStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
@@ -115,7 +125,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -232,6 +246,248 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
return out, compactReferenceMap(refMap), nil
}
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
if !strings.Contains(content, "<whiteboard") {
return content, nil
}
rewrite := func(segment string) (string, error) {
return rewriteWhiteboardFileRefs(runtime, segment)
}
if strings.TrimSpace(format) != "markdown" {
return rewrite(content)
}
var rewriteErrs []error
out := applyOutsideCodeFences(content, func(segment string) string {
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
rewriteErrs = append(rewriteErrs, rewriteErr)
return segment
}
return outSegment
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
var rewriteErrs []error
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
if err != nil {
rewriteErrs = append(rewriteErrs, err)
return raw
}
return rewritten
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
startRaw, body, _, ok := splitWhiteboardElement(raw)
if !ok {
return raw, nil
}
tag, err := parseWhiteboardStartTag(startRaw)
if err != nil {
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
}
pathValue, hasPath := tag.attr(whiteboardPathAttr)
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
if !hasPath && !hasBodyPath {
return raw, nil
}
if hasPath && strings.TrimSpace(body) != "" {
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
}
if hasPath && hasBodyPath {
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
}
typRaw, ok := tag.attr(whiteboardTypeAttr)
if !ok || strings.TrimSpace(typRaw) == "" {
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
}
typ, ok := canonicalWhiteboardFileType(typRaw)
if !ok {
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
}
if hasBodyPath {
pathValue = bodyPath
}
data, err := readWhiteboardPath(runtime, pathValue, typ)
if err != nil {
return "", err
}
tag.setAttr(whiteboardTypeAttr, typ)
tag.removeAttrs(whiteboardPathAttr)
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
}
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
trimmed := strings.TrimSpace(raw)
selfClosing = strings.HasSuffix(trimmed, "/>")
if selfClosing {
return raw, "", true, true
}
startEnd := strings.Index(raw, ">")
if startEnd < 0 {
return "", "", false, false
}
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
if endStart < 0 || endStart < startEnd {
return "", "", false, false
}
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
}
func whiteboardBodyPathRef(body string) (string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
return "", false
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", false
}
return trimmed, true
}
func canonicalWhiteboardFileType(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "svg":
return "svg", true
case "mermaid":
return "mermaid", true
case "plantuml":
return "plantuml", true
default:
return "", false
}
}
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
}
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
WithParam("path").
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
WithCause(err)
}
return string(data), nil
}
func whiteboardExtAllowed(typ string, ext string) bool {
for _, allowed := range whiteboardAllowedExts(typ) {
if ext == allowed {
return true
}
}
return false
}
func whiteboardAllowedExts(typ string) []string {
switch typ {
case "svg":
return []string{".svg"}
case "mermaid":
return []string{".mermaid", ".mmd"}
case "plantuml":
return []string{".plantuml", ".puml", ".pu", ".uml"}
default:
return nil
}
}
func whiteboardExtList(typ string) string {
return strings.Join(whiteboardAllowedExts(typ), ", ")
}
func exampleWhiteboardExt(typ string) string {
exts := whiteboardAllowedExts(typ)
if len(exts) == 0 {
return "txt"
}
return strings.TrimPrefix(exts[0], ".")
}
func whiteboardContentForType(typ string, data string) string {
if typ == "svg" {
return data
}
return escapeXMLText(data)
}
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
messages := make([]string, 0, len(flatErrs))
params := make([]errs.InvalidParam, 0, len(flatErrs))
for _, err := range flatErrs {
messages = append(messages, err.Error())
params = append(params, whiteboardInvalidParamsFromError(err)...)
}
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
WithParam("whiteboard").
WithCause(errors.Join(flatErrs...))
if len(params) > 0 {
validationErr.WithParams(params...)
}
return validationErr
}
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
flatErrs := make([]error, 0, len(rewriteErrs))
for _, err := range rewriteErrs {
var validationErr *errs.ValidationError
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
continue
}
}
flatErrs = append(flatErrs, err)
}
return flatErrs
}
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return nil
}
if len(validationErr.Params) > 0 {
return validationErr.Params
}
if validationErr.Param != "" {
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
}
return nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
@@ -621,6 +877,34 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return whiteboardStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != whiteboardTag {
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
@@ -630,6 +914,15 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
return "", false
}
func (t whiteboardStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
@@ -650,6 +943,31 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
t.Attrs = attrs
}
func (t *whiteboardStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t *whiteboardStartTag) setAttr(name string, value string) {
for i, attr := range t.Attrs {
if attr.Name == name {
t.Attrs[i].Value = value
return
}
}
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
@@ -674,6 +992,25 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
return b.String()
}
func (t whiteboardStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(whiteboardTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
@@ -694,3 +1031,18 @@ func escapeXMLAttr(value string) string {
}
return b.String()
}
func escapeXMLText(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -6,11 +6,13 @@ package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -116,6 +118,61 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
files := map[string]string{
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
"flow.mmd": "flowchart TD\nA --> B",
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
}
for name, content := range files {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error: %v", name, err)
}
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
}, "\n"),
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
got := body["content"].(string)
for _, want := range []string{
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
} {
if !strings.Contains(got, want) {
t.Fatalf("content missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `path="@`) {
t.Fatalf("content still contains whiteboard path attr: %s", got)
}
if _, ok := body["reference_map"]; ok {
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
@@ -407,6 +464,119 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"missing.svg",
"missing.mmd",
"missing.puml",
}, []string{
`whiteboard svg path "missing.svg" cannot be read`,
`whiteboard mermaid path "missing.mmd" cannot be read`,
`whiteboard plantuml path "missing.puml" cannot be read`,
})
}
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
"```",
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
"```",
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"before.svg",
"after.puml",
}, []string{
`whiteboard svg path "before.svg" cannot be read`,
`whiteboard plantuml path "after.puml" cannot be read`,
})
if strings.Contains(err.Error(), "inside.svg") {
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
}
}
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "whiteboard" {
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
}
if validationErr.Cause == nil {
t.Fatal("expected aggregated error to preserve cause")
}
var childValidationErr *errs.ValidationError
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
}
gotParams := make(map[string]string, len(validationErr.Params))
for _, param := range validationErr.Params {
gotParams[param.Name] = param.Reason
}
if len(gotParams) != len(wantParams) {
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
}
for _, param := range wantParams {
reason, ok := gotParams[param]
if !ok {
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
}
if reason == "" {
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
}
}
for _, want := range wantMessages {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q:\n%v", want, err)
}
if !strings.Contains(validationErr.Cause.Error(), want) {
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
}
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)

View File

@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
// original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
}
// hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
// Inject ANSI escape + message ID to verify sanitization
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
out := stderr.String()
if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
}
if !strings.Contains(out, "msg-123") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
if strings.Contains(out, "\nnext") {
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
}
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
}
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
}
}

View File

@@ -0,0 +1,482 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func messageManageID(suffix string) string {
return "msg_abcdefghijklmnop_" + suffix
}
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_message_ids"].([]interface{})
if !ok {
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
}
failed, ok := data["failed_message_ids"].([]interface{})
if !ok {
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
}
return success, failed
}
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected validation error for %s, got nil", param)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem for %s, got %T", param, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr.Param != param {
t.Fatalf("param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected failed precondition error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem, got %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
}
}
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
}
cases := [][]string{
{""},
{" id_with_leading_space_12345"},
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
{"1234567890123456"},
{"short"},
{"msg_abcdefghijklmnop!"},
{"msg_abcdefghijklmnop\t"},
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
}
for _, tc := range cases {
_, err := normalizeMessageManageIDs(tc)
requireMessageManageValidationParam(t, err, "--message-ids")
}
}
func TestMessageModify_Metadata(t *testing.T) {
if MailMessageModify.Command != "+message-modify" {
t.Fatalf("Command = %q", MailMessageModify.Command)
}
if MailMessageModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
}
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
}
requiredScopes := map[string]bool{
"mail:user_mailbox.message:modify": true,
}
for _, scope := range MailMessageModify.Scopes {
delete(requiredScopes, scope)
}
if len(requiredScopes) != 0 {
t.Errorf("Scopes missing %v", requiredScopes)
}
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailMessageModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
}
}
func TestMessageTrash_Metadata(t *testing.T) {
if MailMessageTrash.Command != "+message-trash" {
t.Fatalf("Command = %q", MailMessageTrash.Command)
}
if MailMessageTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
}
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
}
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
}
}
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
token := auth.GetStoredToken("test-app", "ou_testuser")
if token == nil {
t.Fatal("expected test token")
}
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "UNREAD",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
}
}
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "read_receipt_request",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
}
}
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "folderA",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "folderA" {
t.Errorf("add_folder = %v, want folderA", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if removeLabels[0] != "FLAGGED" {
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 1 || success[0] != id || len(failed) != 0 {
t.Errorf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := messageManageID("1")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
t.Fatalf("error = %v, want label intersection validation", err)
}
err = runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-folder")
if !strings.Contains(err.Error(), "use +message-trash") {
t.Fatalf("error = %v, want TRASH validation", err)
}
}
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2 + "," + id1,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 41)
for i := range ids {
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
}
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
messageIDs := body["message_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(messageIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/messages/batch_modify`,
`validation_api_plan`,
`/user_mailboxes/me/labels/customA`,
`/user_mailboxes/me/folders/folderA`,
`will_validate`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["message_ids"].([]interface{})); got != 2 {
t.Fatalf("message_ids len = %d, want 2", got)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "trash newline in repeated flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
},
{
name: "trash tab in csv flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
},
{
name: "modify space in repeated flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
},
{
name: "modify space in csv flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
}
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
t.Fatalf("error = %v, want whitespace/control validation", err)
}
})
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
type messageModifyInput struct {
MessageIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
AddFolder string
CustomLabelIDs []string
CustomFolderID string
ValidationAPIPlans []validationAPIPlan
}
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
// state labels, or a folder move to existing messages in batches of 20.
var MailMessageModify = common.Shortcut{
Service: "mail",
Command: "+message-modify",
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
ConditionalScopes: []string{
"mail:user_mailbox.folder:read",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
},
Validate: validateMessageModify,
DryRun: dryRunMessageModify,
Execute: executeMessageModify,
}
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
_, err := buildMessageModifyInput(rt)
return err
}
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildMessageModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
Set("validation_api_plan", input.ValidationAPIPlans)
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
}
return api
}
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildMessageModifyInput(rt)
if err != nil {
return err
}
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
return err
}
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
return err
}
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
emitMessageManageSummary(rt, messageManageSummary{
SuccessMessageIDs: input.MessageIDs,
FailedMessageIDs: []messageManageFailure{},
}, true)
return nil
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message modify batches failed")
}
return nil
}
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return messageModifyInput{}, err
}
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return messageModifyInput{}, err
}
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return messageModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return messageModifyInput{}, err
}
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
if err != nil {
return messageModifyInput{}, err
}
customLabels := append(customAddLabels, customRemoveLabels...)
customFolderID := ""
if customFolder {
customFolderID = folder
}
return messageModifyInput{
MessageIDs: messageIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
AddFolder: folder,
CustomLabelIDs: customLabels,
CustomFolderID: customFolderID,
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
}, nil
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailMessageTrash = common.Shortcut{
Service: "mail",
Command: "+message-trash",
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateMessageTrash,
DryRun: dryRunMessageTrash,
Execute: executeMessageTrash,
}
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
return err
}
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete messages sequentially in batches of 20").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(messageIDs))
for _, batch := range chunkMessageManageIDs(messageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
Body(map[string]interface{}{"message_ids": batch})
}
return api
}
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return err
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(messageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
map[string]interface{}{"message_ids": batch})
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message trash batches failed")
}
return nil
}

View File

@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"io"
"strings"
"unicode"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const mailMessageManageBatchSize = 20
var messageManageSystemLabels = map[string]string{
"UNREAD": "UNREAD",
"IMPORTANT": "IMPORTANT",
"OTHER": "OTHER",
"FLAGGED": "FLAGGED",
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
}
var messageManageSystemFolders = map[string]string{
"INBOX": "INBOX",
"SENT": "SENT",
"SPAM": "SPAM",
"ARCHIVE": "ARCHIVED",
"ARCHIVED": "ARCHIVED",
}
type messageManageSummary struct {
SuccessMessageIDs []string `json:"success_message_ids"`
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
}
type messageManageFailure struct {
MessageID string `json:"message_id"`
Reason string `json:"reason"`
}
type validationAPIPlan struct {
Method string `json:"method"`
Path string `json:"path"`
WillValidate bool `json:"will_validate"`
}
func normalizeMessageManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
parts, err := splitMessageManageIDTokens(raw)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
if part == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if id != part {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateMessageManageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
return ids, nil
}
func splitMessageManageIDTokens(raw []string) ([]string, error) {
parts := make([]string, 0, len(raw))
for i, token := range raw {
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
}
}
parts = append(parts, strings.Split(token, ",")...)
}
return parts, nil
}
func validateMessageManageID(id string, index int) error {
if len(id) < 16 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
}
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
}
for _, r := range id {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
}
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
}
}
return nil
}
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
labels := make([]string, 0, len(raw))
custom := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
normalized := id
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
normalized = system
} else {
custom = append(custom, id)
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, custom, nil
}
func validateLabelIntersection(add, remove []string) error {
removeSet := make(map[string]struct{}, len(remove))
for _, id := range remove {
removeSet[id] = struct{}{}
}
for _, id := range add {
if _, ok := removeSet[id]; ok {
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
}
}
return nil
}
func normalizeMessageManageFolder(raw string) (string, bool, error) {
if raw == "" {
return "", false, nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
}
if folder != raw {
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
}
if strings.EqualFold(folder, "TRASH") {
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, false, nil
}
return folder, true, nil
}
func chunkMessageManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
end := start + mailMessageManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := validateLabelReadScope(rt); err != nil {
return err
}
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "label not found: %s", id)
}
}
return nil
}
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
if id == "" {
return nil
}
if err := validateFolderReadScope(rt); err != nil {
return err
}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "folder not found: %s", id)
}
return nil
}
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
body := map[string]interface{}{"message_ids": ids}
if len(addLabels) > 0 {
body["add_label_ids"] = addLabels
}
if len(removeLabels) > 0 {
body["remove_label_ids"] = removeLabels
}
if addFolder != "" {
body["add_folder"] = addFolder
}
return body
}
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
seenLabels := map[string]struct{}{}
for _, id := range customLabels {
if _, ok := seenLabels[id]; ok {
continue
}
seenLabels[id] = struct{}{}
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "labels", id),
WillValidate: true,
})
}
if customFolder != "" {
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "folders", customFolder),
WillValidate: true,
})
}
return plans
}
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
if noAPICalls {
fmt.Fprintln(w, "No changes requested; no API calls were made.")
}
for _, item := range summary.FailedMessageIDs {
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
}
})
}

View File

@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MailMessage,
MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread,
MailTriage,
MailWatch,

View File

@@ -65,7 +65,7 @@
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash`
3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份
@@ -82,12 +82,13 @@
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 读单封邮件,`+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **确认投递** 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
9. **已读回执**
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准API Resources 章节的 resource/method 列表可辅助查阅)。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -44,6 +44,8 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`CLI 会在写入前读取文件并展开为内联内容。
### 步骤 2B: SubAgent 使用 SVG 插入图表
主 Agent 启动 SubAgent让它用 `docs +create` / `docs +update` 插入:
@@ -56,6 +58,8 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
- doc token、插入位置标题 / block_id / command

View File

@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>``<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>``<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id

View File

@@ -79,7 +79,7 @@ metadata:
1. `+triage --from spam@x.com` → 列出 N 条结果
2. 展示:"将删除 N 封邮件(发件人 spam@x.com主题确认"
3. 用户确认后 → `*.batch_trash`
3. 用户确认后 → `+message-trash --message-ids ... --yes`
## 身份选择:优先使用 user 身份
@@ -96,13 +96,14 @@ metadata:
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览**`+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读**`+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message``+thread` 读整个会话
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
9. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
10. **已读回执**
4. **整理**标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
5. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送
7. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op已内置 autofix普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
9. **确认投递**立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
10. **编辑草稿** `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
11. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -119,6 +120,8 @@ metadata:
- 查看发送邮件后的投递状态发送成功后查看邮件投递状态也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
- 撤回已发送邮件撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
- 收信规则创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
- 分享邮件到 IM分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
@@ -192,7 +195,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
## 原生 API 调用规则
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
### Step 1 — 用 `-h` 确定要调用的 API必须不可跳过

View File

@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 编辑转发草稿

View File

@@ -0,0 +1,48 @@
# mail +message-modify
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
## Common Commands
```bash
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
lark-cli mail +message-modify --message-ids <id> --add-folder archive
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.

View File

@@ -0,0 +1,41 @@
# mail +message-trash
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
## Common Commands
```bash
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
```
## Flags
| Flag | Required | Notes |
| --- | --- | --- |
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
## Behavior
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
- JSON output is intentionally compact:
```json
{
"success_message_ids": ["id1"],
"failed_message_ids": [
{"message_id": "id2", "reason": "api error"}
]
}
```
## When Raw API Is Still Appropriate
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.

View File

@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 相关命令

View File

@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
```bash
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
```
## 编辑回复草稿

View File

@@ -15,6 +15,7 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 本地生成或校验 SVG Slides / SVGlide 产物 | 先读 `references/svg-slides/README.md`,生成 local publish-ready bundle发布层另走 `+create-svglide` 后续计划 | `references/svg-slides/README.md``scripts/validate_svg_deck.mjs``scripts/svg_slides_bundle.mjs` |
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get``lark-slides-replace-pages.md``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
@@ -29,6 +30,8 @@ metadata:
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — SVG Slides / SVGlide 与当前 XML/SXSD 工作流是不同协议。两者都使用 960x540 画布,但 SVG Slides 使用 `viewBox="0 0 960 540"` 和 `slide:*` SVG 语义XML/SXSD 使用 SML XML。处理 SVG Slides 生成或校验时,先读 [`references/svg-slides/README.md`](references/svg-slides/README.md),不要把 SVG 规则写进 `xml-schema-quick-ref.md`。**
**CRITICAL — PPT 生成与模板编辑硬约束PPT 的尺寸是 960x540确保主体内容在页面边界内。多用生图辅助搜图必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides回读理解每页版式后直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**

View File

@@ -0,0 +1,160 @@
# create-svglide boundary study
## Goal
Use `slides +create` as the design constraint sample for `slides +create-svglide`.
The central rule is:
```text
slides +create is a thin publisher for already-authored slide XML.
slides +create-svglide should be a thin publisher for already-authored SVGlide artifacts.
```
This document is evidence-first. It separates what the existing shortcut actually does from the broader generation and validation work described by the `lark-slides` skill.
## Source Surface
| Area | Files | Why it matters |
| --- | --- | --- |
| Go shortcut implementation | `shortcuts/slides/slides_create.go`, `shortcuts/slides/helpers.go`, `shortcuts/slides/slides_media_upload.go`, `shortcuts/slides/shortcuts.go` | Shows the real runtime boundary of `slides +create`. |
| Unit tests | `shortcuts/slides/slides_create_test.go` | Shows behavior that must not drift silently. |
| E2E proof | `tests/cli_e2e/slides/slides_create_workflow_test.go`, `tests/cli_e2e/slides/coverage.md` | Shows what is proven outside the shortcut body. |
| Skill and references | `skills/lark-slides/SKILL.md`, `skills/lark-slides/references/lark-slides-create.md`, `xml-schema-quick-ref.md`, `validation-checklist.md`, `troubleshooting.md` | Shows which work belongs to agent guidance or scripts instead of Go shortcut code. |
## `slides +create` Responsibility Matrix
| Responsibility | Evidence | Boundary meaning |
| --- | --- | --- |
| Register a write shortcut named `slides +create` for user and bot auth | `shortcuts/slides/slides_create.go:24-43`, `shortcuts/slides/shortcuts.go:8-17` | The command is a shortcut wrapper, not a general slide-generation subsystem. |
| Build a minimal presentation XML shell | `shortcuts/slides/slides_create.go:224-241` | The shortcut creates only the deck container: title plus 960x540 presentation metadata. |
| Create the online XML presentation | `shortcuts/slides/slides_create.go:125-148` | The first real API call is presentation creation. |
| Accept optional `--slides` as a JSON array of `<slide>` XML strings | `shortcuts/slides/slides_create.go:40-52` | Page content is supplied by the caller as final XML strings. |
| Enforce a maximum of 10 inline slide XML strings | `shortcuts/slides/slides_create.go:50-52` | Larger decks must use the lower-level page-create API after container creation. |
| Detect local image placeholders in submitted XML | `shortcuts/slides/helpers.go:113-153` | The shortcut only interprets one small XML convention: `<img src=\"@path\">`. |
| Validate placeholder files before creating the presentation | `shortcuts/slides/slides_create.go:53-67` | Avoids creating an orphan deck for missing/oversized local images. |
| Upload placeholder images and replace them with file tokens | `shortcuts/slides/slides_create.go:163-177`, `shortcuts/slides/slides_media_upload.go:119-138`, `shortcuts/slides/helpers.go:283-309` | Image upload is helper orchestration, not content generation. |
| Submit each supplied slide XML string to the page-create API | `shortcuts/slides/slides_create.go:179-200` | The shortcut forwards caller-authored XML to the backend. |
| Report partial progress when page creation fails | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | It does not roll back; it tells the caller where to resume. |
| Output machine-readable creation results | `shortcuts/slides/slides_create.go:150-219` | The output is an API orchestration receipt. |
| Optionally attempt bot-created deck permission grant | `shortcuts/slides/slides_create.go:215-217`, `shortcuts/slides/slides_create_test.go:66-198` | Bot grant is post-create convenience, not part of content semantics. |
## Behavior Locks From Tests
| Behavior | Evidence | Boundary meaning |
| --- | --- | --- |
| User-mode create returns `xml_presentation_id`, `title`, and `url`, without `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | User-mode output is a creation receipt, not a validation report. |
| Missing `--title` becomes `Untitled` in dry-run and execution | `shortcuts/slides/slides_create_test.go:200-253` | Title normalization is a small deterministic convenience that belongs in the shortcut. |
| `--slides` creates the deck first, then adds pages, then returns `slide_ids` and `slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | Page creation is orchestration after container creation. |
| `--slides []` behaves like no slides | `shortcuts/slides/slides_create_test.go:532-570` | Empty artifact lists should be explicit no-op additions, not special generators. |
| Invalid JSON and more than 10 inline slides fail validation with `Param == "--slides"` | `shortcuts/slides/slides_create_test.go:422-505` | Input-contract errors should be structured and routeable. |
| Missing `xml_presentation_id` from the backend fails | `shortcuts/slides/slides_create_test.go:255-283` | Creation success requires a usable resource id. |
| URL fallback is local and does not call Drive metas or batch query | `shortcuts/slides/slides_create_test.go:649-688` | Avoid adding extra API dependencies when a local receipt can be built. |
| Image placeholders are uploaded once per unique path and rewritten before page creation | `shortcuts/slides/slides_create_test.go:751-854` | Asset handling is publish-boundary plumbing, not design work. |
| Missing local placeholder files fail before any API call | `shortcuts/slides/slides_create_test.go:856-877` | Local artifact existence is a publish-blocking precondition. |
| Dry-run exposes the API plan shape and placeholder ids | `shortcuts/slides/slides_create_test.go:572-602`, `shortcuts/slides/slides_create_test.go:879-900` | Dry-run should describe orchestration, not execute validation-heavy side effects. |
| Readback is proven by E2E as a separate follow-up call | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85`, `tests/cli_e2e/slides/coverage.md:9-16` | Readback is evidence for tests and delivery, not default `Execute` behavior. |
| Bot permission grant is non-fatal and tri-state: granted, skipped, or failed | `shortcuts/slides/slides_create_test.go:66-198` | Convenience post-actions must not turn creation success into failure. |
## `slides +create` Does Not Do
| Non-responsibility | Evidence | Design implication for `+create-svglide` |
| --- | --- | --- |
| Does not generate slide XML from a prompt | `shortcuts/slides/slides_create.go:40-43`, `shortcuts/slides/slides_create.go:158-205` | `+create-svglide` must not become `--topic -> deck`. |
| Does not deeply validate slide XML semantics | `shortcuts/slides/slides_create.go:44-69` | Only minimal publish-blocking validation belongs in the shortcut. |
| Does not preview or repair layout | `shortcuts/slides/slides_create.go:125-221` | Preview and repair belong in skill/scripts or a runner before publish. |
| Does not run readback inside `Execute` | `shortcuts/slides/slides_create.go:125-221`, `tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback is a test/proof step, not default shortcut behavior. |
| Does not guarantee atomic creation | `shortcuts/slides/slides_create.go:194-196`, `shortcuts/slides/slides_create_test.go:354-420` | New publish shortcuts should provide recovery context, not hide partial success. |
| Does not handle more than 10 inline pages | `shortcuts/slides/slides_create.go:18-21`, `shortcuts/slides/slides_create_test.go:441-465` | Bound the first version instead of building a complex batch manager. |
| Does not own visual quality | `skills/lark-slides/SKILL.md:91-127`, `skills/lark-slides/SKILL.md:153-160` | Visual quality gates belong before the shortcut consumes artifacts. |
## Counterexamples
| Tempting requirement | Why it looks tempting | What `slides +create` teaches |
| --- | --- | --- |
| Add readback by default | E2E uses readback to prove persistence. | E2E calls the get API after creation; `Execute` itself stops after outputting the create result. Keep readback optional or outside MVP. |
| Validate every page semantically before calling the backend | Better local errors sound useful. | `+create` only validates JSON shape, count, and local placeholder files; backend owns XML parsing. For SVGlide, only validate fields required to route and publish. |
| Run preview lint and auto-repair | SVGlide has preview tooling. | `+create` does not make layout judgments. Preview lint and repair must remain pre-publish tooling. |
| Accept a prompt and generate the deck | Higher-level UX is attractive. | `+create` consumes final submission artifacts. A prompt-to-deck runner would be a different command or script layer. |
| Hide partial failures by retrying/rebuilding automatically | It feels friendlier. | `+create` surfaces partial progress instead. Recovery should be explicit and resumable. |
## `slides +create-svglide` Allowed Extra Responsibilities
`+create-svglide` can be slightly heavier than `+create` only where SVGlide's input contract requires it. The extra work must still be publish-boundary work, not generation work.
| Extra responsibility | Allowed because | Limit |
| --- | --- | --- |
| Read a SVGlide manifest or run directory | Unlike `--slides`, SVGlide artifacts are file-based. | Normalize to one manifest model immediately; do not infer design intent. |
| Validate manifest schema and page order | Needed to know what to publish. | Validate shape and required fields only. |
| Validate page file existence and path safety | Equivalent to `+create` validating `@path` placeholders. | Do not inspect aesthetics or text quality. |
| Validate publish-required SVGlide fields | The target publish API or parser may require namespace, contract/version, dimensions, or roles before it can accept a page. | Check only required markers; do not rewrite ordinary SVG into protocol SVG in the shortcut. |
| Upload declared local assets | Equivalent to `+create` uploading `@path` images. | Upload and token replacement only; no asset search or generation. |
| Submit SVGlide pages to the target publish API | Equivalent to `+create` submitting each slide XML string. | Keep output and partial-progress behavior explicit; do not assume the CLI must convert to XML if the backend can consume SVGlide directly. |
## `slides +create-svglide` Must Not Own
| Responsibility | Owner |
| --- | --- |
| Research, outline, design brief, slide content planning | `skills/lark-slides` guidance and external runner/scripts |
| SVG authoring | Agent or runner before publish |
| Preview rendering, preview lint, and repair loop | Skill scripts or runner before publish |
| Visual quality scoring | Skill/scripts/quality gate, not shortcut `Execute` |
| Readback as default success criterion | E2E or optional verification flag |
| PPE/Whistle routing as core naming | Environment/profile layer only |
## MVP Scope
Recommended first implementation:
```bash
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
```
MVP behavior:
1. Parse manifest.
2. Validate required fields, page order, file existence, path safety, dimensions, and minimal SVGlide contract markers.
3. Create presentation shell.
4. Upload local assets declared in the manifest.
5. Submit pages to the backend.
6. Output `xml_presentation_id`, `url`, `page_ids` or `slide_ids`, uploaded asset count, and partial-progress context on failure.
MVP exclusions:
1. No prompt input.
2. No generation stages.
3. No preview repair.
4. No default readback.
5. No PPE-specific command name, directory name, or type name.
## Test Boundary For `+create-svglide`
The first test suite should mirror the shape of `slides +create` tests instead of proving the whole SVGlide generation pipeline.
| Test area | Required proof |
| --- | --- |
| Input contract | Invalid manifest, missing page file, unsafe path, and unsupported page count fail with structured params. |
| Dry-run | Shows create, asset upload, and page publish steps with placeholder presentation id and deterministic step labels. |
| Asset handling | Duplicate local assets upload once; page payloads reference uploaded tokens before publish. |
| Partial failure | If the deck exists and page N fails, error includes presentation id, failed page index, and successfully published page count. |
| Bot grant | Inherit user/bot output behavior from `slides +create`; grant failure is reported but not promoted to create failure. |
| E2E | Create/publish result is asserted first; optional readback is a separate proof step unless the command explicitly adds a `--readback` contract. |
## Team Finding
The effective research team for this boundary is:
| Role | Scope |
| --- | --- |
| Code Reader | Extract runtime responsibilities from Go implementation. |
| Test Reader | Extract behavior locks and prove what is outside `Execute`. |
| Skill Boundary Reader | Separate agent/script responsibilities from shortcut responsibilities. |
| Architect/Skeptic | Reject over-broad scope and map only proven `+create` patterns into `+create-svglide`. |
The team's proof standard is not "we read the files"; it is:
```text
Every proposed +create-svglide responsibility must map to either:
1. an existing +create responsibility, or
2. a minimal extra responsibility forced by SVGlide's artifact input shape.
```

View File

@@ -0,0 +1,160 @@
# create-svglide 边界研究
## 目标
`slides +create` 作为 `slides +create-svglide` 的设计约束样本。
核心规则是:
```text
slides +create 是已经写好的 slide XML 的薄发布器。
slides +create-svglide 也应该是已经生成好的 SVGlide 产物的薄发布器。
```
本文档以证据为先,区分现有 shortcut 真实承担的职责,以及 `lark-slides` skill 中描述的更宽泛的生成与验证工作。
## 研究范围
| 范围 | 文件 | 作用 |
| --- | --- | --- |
| Go shortcut 实现 | `shortcuts/slides/slides_create.go``shortcuts/slides/helpers.go``shortcuts/slides/slides_media_upload.go``shortcuts/slides/shortcuts.go` | 确认 `slides +create` 的真实运行时边界。 |
| 单元测试 | `shortcuts/slides/slides_create_test.go` | 确认可被测试锁定、不能随意漂移的行为。 |
| E2E 证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go``tests/cli_e2e/slides/coverage.md` | 确认哪些证明发生在 shortcut 外部。 |
| Skill 与 references | `skills/lark-slides/SKILL.md``skills/lark-slides/references/lark-slides-create.md``xml-schema-quick-ref.md``validation-checklist.md``troubleshooting.md` | 确认哪些工作属于 agent 指导或脚本,而不是 Go shortcut。 |
## `slides +create` 职责矩阵
| 职责 | 证据 | 边界含义 |
| --- | --- | --- |
| 注册一个名为 `slides +create` 的写操作 shortcut支持 user 和 bot 身份 | `shortcuts/slides/slides_create.go:24-43``shortcuts/slides/shortcuts.go:8-17` | 这是 shortcut 封装,不是通用幻灯片生成系统。 |
| 构造最小 presentation XML 外壳 | `shortcuts/slides/slides_create.go:224-241` | shortcut 只创建 deck 容器:标题和 960x540 presentation 元数据。 |
| 创建在线 XML presentation | `shortcuts/slides/slides_create.go:125-148` | 第一个真实 API 调用是创建 presentation。 |
| 接收可选 `--slides`,格式为 `<slide>` XML 字符串 JSON 数组 | `shortcuts/slides/slides_create.go:40-52` | 页面内容由调用方以最终 XML 字符串形式提供。 |
| 限制一次内联提交最多 10 页 slide XML | `shortcuts/slides/slides_create.go:50-52` | 更大的 deck 应先创建容器,再用底层 page-create API 追加页面。 |
| 检测已提交 XML 里的本地图片占位符 | `shortcuts/slides/helpers.go:113-153` | shortcut 只理解一个很窄的 XML 约定:`<img src="@path">`。 |
| 创建 presentation 前校验占位符文件 | `shortcuts/slides/slides_create.go:53-67` | 避免因为本地图片缺失或超限而创建孤儿 deck。 |
| 上传占位符图片并替换为 file token | `shortcuts/slides/slides_create.go:163-177``shortcuts/slides/slides_media_upload.go:119-138``shortcuts/slides/helpers.go:283-309` | 图片上传是发布边界上的 helper 编排,不是内容生成。 |
| 把每个调用方提供的 slide XML 字符串提交给 page-create API | `shortcuts/slides/slides_create.go:179-200` | shortcut 把调用方写好的 XML 转交给后端。 |
| 页面创建失败时报告部分进度 | `shortcuts/slides/slides_create.go:194-196``shortcuts/slides/slides_create_test.go:354-420` | 不回滚;告诉调用方从哪里恢复。 |
| 输出机器可读的创建结果 | `shortcuts/slides/slides_create.go:150-219` | 输出是 API 编排回执。 |
| bot 创建 deck 后可选尝试给当前用户授权 | `shortcuts/slides/slides_create.go:215-217``shortcuts/slides/slides_create_test.go:66-198` | bot grant 是创建后的便利动作,不属于内容语义。 |
## 测试锁定的行为
| 行为 | 证据 | 边界含义 |
| --- | --- | --- |
| user 模式创建返回 `xml_presentation_id``title``url`,不返回 `permission_grant` | `shortcuts/slides/slides_create_test.go:23-63` | user 模式输出是创建回执,不是验证报告。 |
| 省略 `--title`dry-run 和真实执行都归一为 `Untitled` | `shortcuts/slides/slides_create_test.go:200-253` | 标题归一是适合放在 shortcut 内的小型确定性便利。 |
| `--slides` 会先创建 deck再添加页面最后返回 `slide_ids``slides_added` | `shortcuts/slides/slides_create_test.go:285-352` | 页面创建是容器创建后的编排。 |
| `--slides []` 等价于不传 slides | `shortcuts/slides/slides_create_test.go:532-570` | 空产物列表应是明确的无追加操作,不应触发特殊生成逻辑。 |
| 非法 JSON 和超过 10 个内联 slides 会以 `Param == "--slides"` 的校验错误失败 | `shortcuts/slides/slides_create_test.go:422-505` | 输入契约错误必须结构化,便于调用方路由处理。 |
| 后端缺少 `xml_presentation_id` 时失败 | `shortcuts/slides/slides_create_test.go:255-283` | 创建成功必须拿到可用资源 id。 |
| URL fallback 在本地构造,不调用 Drive metas 或 batch query | `shortcuts/slides/slides_create_test.go:649-688` | 能用本地回执构造的内容,不应增加额外 API 依赖。 |
| 图片占位符按唯一路径上传一次,并在页面创建前完成替换 | `shortcuts/slides/slides_create_test.go:751-854` | 素材处理是发布边界的管道能力,不是设计工作。 |
| 本地占位符文件缺失时,在任何 API 调用前失败 | `shortcuts/slides/slides_create_test.go:856-877` | 本地产物存在性是发布前置条件。 |
| Dry-run 暴露 API 计划形状和占位 id | `shortcuts/slides/slides_create_test.go:572-602``shortcuts/slides/slides_create_test.go:879-900` | Dry-run 应描述编排计划,而不是执行重型校验副作用。 |
| Readback 在 E2E 中作为单独 follow-up 调用证明 | `tests/cli_e2e/slides/slides_create_workflow_test.go:32-85``tests/cli_e2e/slides/coverage.md:9-16` | Readback 是测试和交付证据,不是默认 `Execute` 行为。 |
| Bot 授权是非致命三态granted、skipped、failed | `shortcuts/slides/slides_create_test.go:66-198` | 便利性的后置动作不应把创建成功升级成失败。 |
## `slides +create` 不负责的事情
| 非职责 | 证据 | 对 `+create-svglide` 的设计含义 |
| --- | --- | --- |
| 不从 prompt 生成 slide XML | `shortcuts/slides/slides_create.go:40-43``shortcuts/slides/slides_create.go:158-205` | `+create-svglide` 不能变成 `--topic -> deck`。 |
| 不深度校验 slide XML 语义 | `shortcuts/slides/slides_create.go:44-69` | shortcut 内只应放发布阻塞级的最小校验。 |
| 不预览或修复布局 | `shortcuts/slides/slides_create.go:125-221` | preview 和 repair 属于发布前的 skill/scripts 或 runner。 |
| 不在 `Execute` 内做 readback | `shortcuts/slides/slides_create.go:125-221``tests/cli_e2e/slides/slides_create_workflow_test.go:68-85` | Readback 是测试/证明步骤,不是默认 shortcut 行为。 |
| 不保证原子创建 | `shortcuts/slides/slides_create.go:194-196``shortcuts/slides/slides_create_test.go:354-420` | 新发布类 shortcut 应提供恢复上下文,而不是隐藏部分成功。 |
| 不处理超过 10 个内联页面 | `shortcuts/slides/slides_create.go:18-21``shortcuts/slides/slides_create_test.go:441-465` | 第一版应设边界,而不是一开始实现复杂批处理器。 |
| 不负责视觉质量 | `skills/lark-slides/SKILL.md:91-127``skills/lark-slides/SKILL.md:153-160` | 视觉质量门禁应发生在 shortcut 消费产物之前。 |
## 反例
| 诱人的需求 | 为什么看起来合理 | `slides +create` 给出的约束 |
| --- | --- | --- |
| 默认加入 readback | E2E 用 readback 证明持久化。 | E2E 是创建后另调 get API`Execute` 输出创建结果后即结束。Readback 应保持可选或放在 MVP 外。 |
| 调后端前语义校验每一页 | 本地错误更友好。 | `+create` 只校验 JSON 形状、页数、本地占位符文件XML 解析由后端负责。SVGlide 也只校验发布路由必需字段。 |
| 运行 preview lint 并自动 repair | SVGlide 有 preview 工具链。 | `+create` 不做布局判断。Preview lint 和 repair 应留在发布前工具链。 |
| 接受 prompt 并生成 deck | 高层 UX 很吸引人。 | `+create` 消费最终提交物。Prompt-to-deck runner 应是另一层命令或脚本。 |
| 通过自动重试/重建隐藏部分失败 | 看起来更友好。 | `+create` 暴露部分进度。恢复应该显式、可续跑。 |
## `slides +create-svglide` 允许新增的职责
`+create-svglide` 只能在 SVGlide 输入契约强制要求的地方比 `+create` 稍重。新增工作仍必须属于发布边界,而不是生成边界。
| 额外职责 | 允许原因 | 限制 |
| --- | --- | --- |
| 读取 SVGlide manifest 或 run directory | 与 `--slides` 不同SVGlide 产物是文件型产物。 | 立即归一化为一个 manifest 模型;不要推断设计意图。 |
| 校验 manifest schema 和页序 | 需要知道要发布什么。 | 只校验形状和必填字段。 |
| 校验页面文件存在性和路径安全 | 等价于 `+create` 校验 `@path` 占位符。 | 不检查美观度或文本质量。 |
| 校验发布必需的 SVGlide 字段 | 目标发布 API 或 parser 可能需要 namespace、contract/version、尺寸或 role 才能接收页面。 | 只检查必需标记;不要在 shortcut 中把普通 SVG 重写成协议 SVG。 |
| 上传声明的本地素材 | 等价于 `+create` 上传 `@path` 图片。 | 只做上传和 token 替换;不做素材搜索或生成。 |
| 把 SVGlide 页面提交给目标发布 API | 等价于 `+create` 提交每个 slide XML 字符串。 | 保持输出和部分进度语义明确;如果后端能直接消费 SVGlide不要假设 CLI 必须转 XML。 |
## `slides +create-svglide` 必须不拥有的职责
| 职责 | 所属边界 |
| --- | --- |
| research、outline、design brief、slide content planning | `skills/lark-slides` 指导和外部 runner/scripts |
| SVG authoring | agent 或 runner在发布前完成 |
| preview rendering、preview lint、repair loop | skill scripts 或 runner在发布前完成 |
| 视觉质量评分 | skill/scripts/quality gate不属于 shortcut `Execute` |
| readback 作为默认成功标准 | E2E 或可选验证 flag |
| PPE/Whistle 路由进入核心命名 | 只能属于环境/profile 层 |
## MVP 范围
推荐第一版实现:
```bash
lark-cli slides +create-svglide --manifest ./svglide-run/manifest.json --as user
```
MVP 行为:
1. 解析 manifest。
2. 校验必填字段、页序、文件存在性、路径安全、尺寸、最小 SVGlide contract 标记。
3. 创建 presentation 外壳。
4. 上传 manifest 声明的本地素材。
5. 把页面提交给后端。
6. 输出 `xml_presentation_id``url``page_ids``slide_ids`、上传素材数量,以及失败时的部分进度上下文。
MVP 排除项:
1. 不接受 prompt 输入。
2. 不包含生成阶段。
3. 不做 preview repair。
4. 不默认 readback。
5. 不在命令名、目录名或类型名中包含 PPE。
## `+create-svglide` 测试边界
第一版测试应镜像 `slides +create` 的测试形状,而不是证明完整 SVGlide 生成流水线。
| 测试范围 | 必须证明 |
| --- | --- |
| 输入契约 | 非法 manifest、缺失页面文件、不安全路径、不支持的页数以结构化 param 失败。 |
| Dry-run | 展示 create、asset upload、page publish 步骤,包含占位 presentation id 和确定性的 step label。 |
| 素材处理 | 重复本地素材只上传一次;页面 payload 在发布前引用已上传 token。 |
| 部分失败 | deck 已存在但第 N 页失败时,错误包含 presentation id、失败页序号、已成功发布页数。 |
| Bot grant | 继承 `slides +create` 的 user/bot 输出行为grant 失败不升级成 create 失败。 |
| E2E | 先断言 create/publish 结果;可选 readback 作为单独证明步骤,除非命令显式加入 `--readback` 契约。 |
## Team 结论
适合研究这个边界的 team 是:
| 角色 | 范围 |
| --- | --- |
| Code Reader | 从 Go 实现中抽取运行时职责。 |
| Test Reader | 抽取行为锁定点,并证明哪些行为不在 `Execute` 内。 |
| Skill Boundary Reader | 区分 agent/script 职责和 shortcut 职责。 |
| Architect/Skeptic | 拒绝过宽 scope只把已被 `+create` 证明的模式映射到 `+create-svglide`。 |
这个 team 的证明标准不是“读过文件”,而是:
```text
每一个 proposed +create-svglide 职责都必须映射到:
1. 一个已有 +create 职责;或
2. 一个由 SVGlide 产物输入形态强制产生的最小额外职责。
```

View File

@@ -0,0 +1,41 @@
# SVG Slides Local Generation
This reference family is for local SVG Slides generation and validation.
It is not the Lark Slides XML/SXSD workflow and it is not the publish shortcut. Use it to produce a local publish-ready bundle that a future `slides +create-svglide` publisher can consume.
## Read Routes
| Task | Read first | Then read |
|---|---|---|
| Generate a new SVG deck | `workflow.md` | `design-brief.md`, `protocol.md`, `authoring-rules.md`, `visual-design.md`, `validation.md` |
| Repair protocol failures | `validation.md` | `protocol.md`, `authoring-rules.md` |
| Improve visual quality | `visual-design.md` | `design-brief.md`, `workflow.md` |
| Use charts | `chart-workflow.md` | `protocol.md`, `validation.md` |
| Continue an existing deck | `editing-existing-decks.md` | `workflow.md`, `protocol.md` |
| Audit provenance | `source/split-manifest.json` | `source/full.debranded.md` |
## Boundary
Generation and validation produce a local publish-ready bundle.
A future SVG Slides publisher consumes this bundle. That publishing path is intentionally outside this reference family.
A local bundle may set `publish_ready=true`; it must not claim it is published.
## Canvas Decision
This CLI adaptation uses a 960x540 SVG canvas: `viewBox="0 0 960 540"`.
The source snapshot is preserved for provenance and coverage audit. Where the source describes a different default canvas, the CLI adaptation layer intentionally normalizes generated SVG Slides to 960x540.
## Required Local Gates
1. `node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir> --json`
2. `node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"`
3. Browser text-boundary check when Playwright is available.
## Source Coverage
- Covers manifest sections: title
- Coverage mode: routing entry; source text is preserved in `source/full.debranded.md`, while this file points workers to the coverage-preserving split docs.

View File

@@ -0,0 +1,79 @@
# SVG Slides Authoring Rules
## Required Authoring Pattern
Write complete slide files. A slide edit is not a fragment, patch, or HTML page.
Use this order:
1. Optional `<defs>`.
2. One background as the first rendered child.
3. Top-level shapes, images, charts, groups, and optional notes.
Every rendered element that the slide engine must understand needs the appropriate `slide:role`. Do not depend on generic browser rendering when the protocol has an explicit semantic role.
## Forbidden Constructs
Do not use:
- `<style>` blocks;
- `class=`;
- `<div>` or `<section>` wrappers in text `foreignObject`;
- bare text under `foreignObject`;
- SVG `<text>`;
- SVG `<marker>`;
- hex colors;
- named colors;
- `none` for `fill` or `stroke`;
- role-less primitives in the rendered slide body.
## Text Boxes
Use plain text boxes for text-only content:
```xml
<foreignObject slide:role="shape" slide:shape-type="text" x="96" y="96" width="640" height="120" style="font-size:32px;color:rgba(15,23,42,1);line-height:1.2">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Main argument</p>
</foreignObject>
```
Use shape-with-text only when the object is truly one styled box with one text block. If the card has multiple parts, use `<g slide:role="group">`.
## Image Elements
Use images when there is a real image asset or generated visual. The SVG file references the local asset path.
Informational images such as charts, diagrams, screenshots, and infographics must preserve their original ratio. Decorative images may be composed more freely, but should still fit the resolved design brief.
Unless the user explicitly requests no images, cover, section divider, and closing pages should use a large hero image or generated visual. Full-bleed image backgrounds use `<image slide:role="background">`; large non-background images use `<image slide:role="image" slide:shape-type="image">`.
When text sits on an image, place a semi-transparent `<rect slide:role="shape" slide:shape-type="shape">` scrim or a solid text zone after the image and before the text. Do not use SVG `<mask>` for this readability layer.
Generated cover, section divider, or closing images must not contain baked-in text. Render text as slide text on top of the image.
## Chart Embeds
A chart is an external SVG sidecar referenced by:
```xml
<rect slide:role="chart" href="resources/charts/example.svg" x="120" y="180" width="800" height="500"/>
```
Do not hand-draw a chart from primitives when the slide's point depends on a real quantitative data series. Use the chart workflow to generate the sidecar first.
## Custom Paths
Custom paths require accurate bounds. `slide:width` and `slide:height` describe the real extent of the path data, not the full canvas.
If a path has not been normalized, measure its bounding box before writing the final slide. Oversized path boxes make selection, hit testing, and layout misleading.
## Grouped Cards
Use `<g slide:role="group">` for a multi-element cluster: card background, badge, icon, title, body, chart, image, or connector. Each child still carries its own role.
Do not use `<g slide:role="shape">` as a generic container. It is only for the shape-with-text form.
## Source Coverage
- Covers manifest sections: slides_edit_tool, image_usage, compute_custom_shape_bbox_tool
- Coverage mode: preserve authoring constraints and tool semantics that affect generated SVG structure.

View File

@@ -0,0 +1,64 @@
# SVG Slides Chart Workflow
## When To Use A Chart
Use a chart when the slide's point depends on a real quantitative series:
- trend;
- multi-category comparison;
- part-to-whole split;
- distribution;
- ranking;
- two-dimensional positioning.
For single numbers or trivial two-bucket comparisons, prefer a large text callout unless the comparison needs a chart to be understood.
## When Not To Use A Chart
Do not generate a chart for vague, unsourced, decorative, or invented data. Do not choose a chart type because the raw data happens to look compatible; choose it because the takeaway requires that representation.
When in doubt, a sorted bar chart is safer than a pie or doughnut.
## Chart Sidecar Contract
A chart is generated as an SVG sidecar before slide authoring and embedded by reference.
The generation request must decide the takeaway first. The takeaway must be faithful to the data and short enough to guide chart design.
The request must include:
- chart type;
- JSON data matching that type;
- style matching the destination slide;
- actual on-slide width and height;
- output path under `resources/charts/`.
The declared chart width should match the embed width. Chart internals derive text size from width. Do not declare a wide chart and embed it in a narrow slot.
## Embed Contract
Embed a generated chart with:
```xml
<rect slide:role="chart" href="resources/charts/name.svg" x="120" y="180" width="800" height="500"/>
```
The embed width and height must match the chart sidecar's intended display size. Keep a 16:10-ish chart area when possible to avoid letterboxing.
One chart should carry one distinct insight. Pair charts with short callouts or labels, and vary chart composition across the deck.
## Validation Notes
Static deck validation confirms the chart placeholder shape, not the correctness of the chart sidecar data. Review chart sidecars for:
- source-backed data;
- truthful takeaway;
- readable labels;
- width at or above the practical floor;
- matching palette;
- intact `href`.
## Source Coverage
- Covers manifest sections: generate_svg_chart_tool
- Coverage mode: preserve chart generation, data contract, rendering constraints, and validation expectations.

View File

@@ -0,0 +1,80 @@
# SVG Slides Design Brief
## Inputs
Resolve the design brief after these inputs are known:
- topic and goal;
- audience;
- delivery mode;
- language;
- page count when known;
- source material;
- user-fixed brand, color, or content constraints;
- one to three short visual-direction phrases.
Do not ask the user to choose tone, density, palette, or typography unless they volunteered hard constraints.
## Output Contract
The brief must produce:
- `narrative_spine`;
- `depth`;
- `tone`;
- `visual_system`.
These outputs govern outline, content density, wording, asset choices, typography, color, layout, and decoration.
## narrative_spine
`narrative_spine` defines the slide sequence discipline. It is the default source of order, sectioning, and narrative movement.
The user can override it by giving or editing an outline. After that point, the user outline wins.
## depth
`depth` decides altitude and density:
- how much context each slide carries;
- what to include and exclude;
- how many main points per slide;
- how source evidence should appear;
- whether a page should split instead of cram.
## tone
`tone` controls writing style and evidence posture. It should reflect the audience and delivery mode.
Presented decks can use shorter on-slide wording because the speaker carries context. Self-read decks need more complete explanatory text but still must avoid walls of text.
## visual_system
`visual_system` is the authority for look and feel. It should include:
- color logic;
- typography category and treatment;
- layout grammar;
- imagery or material direction;
- page-role imagery defaults for cover, section divider, and closing pages;
- decoration and motif rules;
- constraints to avoid.
Unless the user explicitly requests no images, `visual_system` must specify how cover, section divider, and closing pages use a high-impact hero image or generated visual. The brief should describe the imagery subject, treatment, crop attitude, and how foreground text stays readable.
Font mapping must preserve the same category and treatment. Do not swap serif and sans, ignore uppercase treatment, or pick generic fonts when the brief calls for a distinctive style.
## How It Drives Generation
Use the brief in this order:
1. Shape the outline from `narrative_spine`.
2. Size the content from `depth`.
3. Write titles and evidence from `tone`.
4. Build the deck-level style from `visual_system`.
5. Author each slide's layout from the content logic plus the visual system.
## Source Coverage
- Covers manifest sections: resolve_design_brief
- Coverage mode: preserve design brief inputs, output contract, and downstream influence on outline and page authoring.

View File

@@ -0,0 +1,39 @@
# SVG Slides Editing Existing Decks
## Continue Existing Deck
When the user asks to continue, edit, extend, or repair an existing uploaded deck, operate on the existing converted project instead of recreating from scratch.
Preserve every existing page unless the user asks to change it. A page with minimal content should remain minimal if that is what the source deck contained.
## Preserve Existing Pages
For text or layout changes, edit only the target slide files. Preserve styling by default. Restyle only when the user explicitly asks.
Preserve media, chart, video, and audio blocks verbatim when the request does not touch them.
## Add Or Delete Pages
Add pages through the organize workflow, then author the new standalone SVG pages.
Delete only pages the user asked to remove. Do not rerun the new-deck outline workflow over an existing deck; it can overwrite existing slide files and lose original pages.
## Template Reference Boundary
An uploaded reference can mean two different things:
- Continue or edit this deck: preserve and modify that deck.
- Create a new deck inspired by this reference: author fresh SVG using the normal create workflow.
Clarify when the user's wording does not identify which behavior they want.
## PPTX Conversion Boundary
Converted decks may contain imported chart placeholders or media. Preserve legacy chart references unless the user asks to update chart data, type, theme, or emphasis.
If a chart is resized materially, regenerate the chart sidecar with the new dimensions rather than only squeezing the existing placeholder.
## Source Coverage
- Covers manifest sections: slide_organize_tool, slides_convert_tool, slides_parse_template_tool
- Coverage mode: preserve existing-deck continuation, conversion, and template parsing boundaries without turning them into publish behavior.

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="chart_embed" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="56" width="680" height="72" style="font-size:36px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(17,24,39,1);font-weight:800;line-height:1.15;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:36px;line-height:1.15;color:rgba(17,24,39,1);letter-spacing:0px">Chart is a referenced sidecar</h2>
</foreignObject>
<rect slide:role="chart" href="resources/charts/example_bar.svg" x="80" y="160" width="560" height="350"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="690" y="190" width="190" height="118" style="font-size:19px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(55,65,81,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:19px;line-height:1.38;color:rgba(55,65,81,1);letter-spacing:0px">The chart payload lives outside the slide and is referenced by href.</p>
</foreignObject>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="group_card" viewBox="0 0 960 540">
<defs>
<linearGradient id="card_grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="rgba(255,255,255,1)"/>
<stop offset="100%" stop-color="rgba(226,232,240,1)"/>
</linearGradient>
</defs>
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(241,245,249,1)"/>
<g slide:role="group" id="card_primary">
<rect slide:role="shape" slide:shape-type="round-rect" x="120" y="140" width="520" height="300" rx="24" ry="24" fill="url(#card_grad)" stroke="rgba(148,163,184,1)" stroke-width="1"/>
<circle slide:role="shape" slide:shape-type="circle" cx="180" cy="206" r="26" fill="rgba(37,99,235,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="224" y="178" width="340" height="50" style="font-size:28px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.2;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h2 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:28px;line-height:1.2;color:rgba(15,23,42,1);letter-spacing:0px">Grouped card</h2>
</foreignObject>
<foreignObject slide:role="shape" slide:shape-type="text" x="154" y="264" width="420" height="86" style="font-size:20px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(51,65,85,1);font-weight:500;line-height:1.38;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:20px;line-height:1.38;color:rgba(51,65,85,1);letter-spacing:0px">A card is a group; every visual child still carries its own slide role.</p>
</foreignObject>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="minimal_slide" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(248,250,252,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="760" height="92" style="font-size:42px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);font-weight:800;line-height:1.12;text-align:left;vertical-align:top;letter-spacing:0px;padding:0px">
<h1 xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:42px;line-height:1.12;color:rgba(15,23,42,1);letter-spacing:0px">One protocol-compliant SVG slide</h1>
</foreignObject>
</svg>

After

Width:  |  Height:  |  Size: 753 B

View File

@@ -0,0 +1,94 @@
# SVG Slides Protocol
## Canvas
- Each page is one standalone SVG file.
- Root must contain `xmlns="http://www.w3.org/2000/svg"`.
- Root must contain `xmlns:slide="https://slides.bytedance.com/ns"`.
- Root must contain `slide:role="slide"`.
- Root must contain an `id`.
- Root must contain `viewBox="0 0 960 540"`.
- Child coordinates are in viewBox units.
- Do not rely on HTML document behavior. SVG nodes use SVG semantics; XHTML appears only inside approved `foreignObject` children.
- This 960x540 canvas is the CLI adaptation target. The preserved source snapshot may mention other defaults, but generated local bundles must use 960x540.
## Background
- Exactly one rendered background is required.
- Optional `<defs>` may appear first.
- The first rendered child after optional `<defs>` must be a `<rect>` or `<image>` with `slide:role="background"`.
- Background must cover the full canvas.
- Gradient backgrounds must reference gradients declared in the same slide's `<defs>`.
- A full-bleed image background should be an `<image slide:role="background">`.
- Text scrims over image backgrounds are normal shape overlays after the background, not additional backgrounds.
## Text
- Plain text uses `foreignObject slide:role="shape" slide:shape-type="text"`.
- Text `foreignObject` needs numeric `x`, `y`, `width`, and `height`.
- The first direct XHTML child must be `p`, `ul`, `ol`, `h1`, `h2`, `h3`, or `small`.
- Do not wrap text in `div` or `section`.
- Do not put bare text directly under `foreignObject`.
- Text style belongs in `style`.
- `font-size` must include `px`.
- Text color must be `rgb(...)` or `rgba(...)`.
- Text boxes must be sized to fit; static validation does not prove rendered wrapping.
## Shapes And Groups
- Geometry needs `slide:role="shape"` and a meaningful `slide:shape-type`.
- Common geometry includes `rect`, `ellipse`, `circle`, `path`, and `line`.
- Multi-element cards use `<g slide:role="group">`.
- Children inside a group still keep their own `slide:role`.
- A shape-with-text group is only for one geometry plus one text block. Cards with badges, icons, charts, or multiple text blocks must be regular groups.
- Custom paths must declare a meaningful `slide:width` and `slide:height` that match the path's real bounding box.
## Lines
- Lines use `<line slide:role="shape" slide:shape-type="line">`.
- Arrows use `slide:start-arrow` or `slide:end-arrow`.
- SVG marker arrows are forbidden.
## Images
- Images use `<image slide:role="image" slide:shape-type="image" href="...">`.
- Informational images preserve source aspect ratio.
- Do not wrap a single image in a group unless it is truly part of a larger multi-element composition.
- Borders and shadows belong on the image element itself when used.
## Charts
- Charts use `<rect slide:role="chart" href="..." x="..." y="..." width="..." height="...">`.
- The rect is a chart placeholder; it is not a drawn rectangle.
- Place charts at top level or inside `<g slide:role="group">`.
- Preserve chart `href` verbatim unless the user asks to change chart data, type, emphasis, theme, or source.
## Notes
- Speaker notes are optional and do not render on canvas.
- At most one `<slide:note>` may appear.
- Notes contain direct paragraph children.
## Colors
- Use `rgb(...)`, `rgba(...)`, or `url(#id)`.
- Do not use hex colors.
- Do not use named colors.
- Do not use `none` for `fill` or `stroke`; use `rgba(0,0,0,0)` for transparent fills.
## Animation
- Animation is part of delivery, not decoration.
- Most slides should be static.
- Presented decks may use progressive reveal for complex steps, charts, processes, timelines, or comparisons.
- Self-read, formal, board, or consulting decks should read fully without clicks.
- Use at most three builds on a slide.
- Use one effect type per slide.
- Animated elements need explicit `id`.
- Animate top-level elements or top-level groups.
- Use one deck-level page transition when needed; do not vary transition style slide by slide.
## Source Coverage
- Covers manifest sections: svg_reference, svg_document_rules
- Coverage mode: preserve hard SVG protocol requirements from the source while applying the CLI canvas adaptation to 960x540; visual guidance belongs in `visual-design.md`, not here.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"version": "svg-slides.split-manifest.v1",
"source": "skills/lark-slides/references/svg-slides/source/full.debranded.md",
"source_export": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.debranded.md",
"source_role": "provenance_and_coverage_authority_not_default_runtime_context",
"sections": [
{"id": "title", "lines": [1, 1], "target": "README.md"},
{"id": "system_prompt_workflow", "lines": [3, 196], "target": "workflow.md"},
{"id": "svg_reference", "lines": [198, 865], "target": "protocol.md"},
{"id": "resolve_design_brief", "lines": [867, 1080], "target": "design-brief.md"},
{"id": "deck_design_reference_catalog", "lines": [1082, 1234], "target": "visual-design.md"},
{"id": "slide_outline_tool", "lines": [1236, 1254], "target": "workflow.md"},
{"id": "activate_slides_edit_tool", "lines": [1256, 1262], "target": "workflow.md"},
{"id": "slides_edit_tool", "lines": [1264, 1281], "target": "authoring-rules.md"},
{"id": "svg_document_rules", "lines": [1283, 1287], "target": "protocol.md"},
{"id": "image_usage", "lines": [1289, 1291], "target": "authoring-rules.md"},
{"id": "incremental_processing", "lines": [1293, 1331], "target": "workflow.md"},
{"id": "finish_slides_edit_tool", "lines": [1333, 1339], "target": "validation.md"},
{"id": "slide_organize_tool", "lines": [1341, 1347], "target": "editing-existing-decks.md"},
{"id": "compute_custom_shape_bbox_tool", "lines": [1349, 1355], "target": "authoring-rules.md"},
{"id": "generate_svg_chart_tool", "lines": [1357, 2356], "target": "chart-workflow.md"},
{"id": "slides_convert_tool", "lines": [2358, 2395], "target": "editing-existing-decks.md"},
{"id": "slides_parse_template_tool", "lines": [2397, 2420], "target": "editing-existing-decks.md"}
]
}

View File

@@ -0,0 +1,120 @@
# SVG Slides Validation
## Static Protocol Validator
Run:
```bash
node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> --json
```
The validator checks hard protocol rules:
- standalone SVG root;
- `slide:role="slide"`;
- required namespaces;
- `viewBox="0 0 960 540"`;
- background order;
- forbidden style blocks and CSS classes;
- forbidden text wrappers;
- color syntax;
- text `font-size` units;
- line role and arrow semantics;
- XML parseability.
The validator is a hard gate for publish-ready local bundles.
## Browser Text Boundary Check
Static XML cannot prove final rendered wrapping, CJK font fallback, or actual text height. Run browser text-boundary QA when Playwright is available:
```bash
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> --out /tmp/svg-slides-text-bounds.json
```
If Playwright is unavailable, the script exits 2 and explains the missing optional dependency.
## Bundle Manifest
Run:
```bash
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<title>"
```
The bundle manifest records:
- protocol version;
- title;
- slide list;
- validation receipt paths;
- `publish_ready=true`;
- `published=false`.
## Receipt Requirements
A local publish-ready bundle needs:
- `manifest.json`;
- `receipts/validate_svg_deck.json`;
- optional browser text-boundary receipt when browser QA ran;
- slide files listed in deterministic order.
## What Passing Validation Does Not Prove
Passing validation does not prove visual excellence, source quality, chart truth, or backend acceptance. It proves that the generated local SVG files obey the hard protocol rules represented by the validator.
Always separate:
- protocol pass;
- browser text-boundary pass;
- visual design review;
- live publish proof.
## Local Publish-Ready Bundle
Run:
```bash
node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title "<deck title>"
```
The command writes:
- `manifest.json`
- `receipts/validate_svg_deck.json`
The manifest uses:
```json
{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"size": {"width": 960, "height": 540},
"publish_ready": true,
"published": false
}
```
`publish_ready=true` means local static validation passed. It does not mean the deck was published to Lark Slides.
## Browser Text Boundary QA
When Playwright is available in the development environment, run:
```bash
node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir> --out receipts/preview_text_bounds.json
```
Exit codes:
- `0`: no text-boundary problems.
- `1`: rendered text overflow was detected.
- `2`: the script could not run, for example Playwright is unavailable.
This browser check is a generation-quality gate. It is not a publish API proof.
## Source Coverage
- Covers manifest sections: finish_slides_edit_tool
- Coverage mode: preserve finish/validation gates and explicitly separate protocol pass from visual quality pass.

View File

@@ -0,0 +1,74 @@
# SVG Slides Visual Design
## Typography
Use real font families that are likely to render. Keep a stable display/body pairing across the deck.
Titles, hero numbers, and key labels may use display fonts. Body text should use readable fonts. English and CJK decks need compatible font choices rather than generic fallback everywhere.
Do not switch typography per slide without a structural reason.
## Layout Freedom
SVG Slides gives full coordinate-level layout control. Use that control to encode the page's logic.
Start each slide by identifying the relationship in the content:
- comparison;
- sequence;
- timeline;
- cycle;
- hierarchy;
- matrix or quadrant;
- funnel;
- part-to-whole;
- cause to effect;
- evidence to implication.
Then compose a bespoke structure using position, scale, alignment, grouping, flow direction, connectors, depth, and contrast. A layout invented for the slide's actual logic is better than a canned diagram.
## Visual Differentiation
Every substantive slide should have a visual idea: image, chart, diagram, process, comparison, spatial map, large number, table-like structure, or custom shape system.
Avoid repeating title-plus-bullets. Reuse deck-level motif and style, not the exact same page layout.
Cover, section divider, and closing pages are not exceptions. Unless the user explicitly requests no images, make these pages image-led with a high-impact hero image or generated visual. Text over imagery must use an intentional readability treatment, such as a translucent scrim or solid text zone, instead of relying on contrast by accident.
## Density
Density comes from audience and delivery mode. Split rather than cram when a slide needs more than one central idea.
Each slide should defend one central idea. Content slide titles should be declarative arguments, not topic labels. Cover, section, and closing slides can use shorter labels.
## Anti-Patterns
Avoid:
- generic white slides with bullets only;
- the same card grid on every page;
- low-contrast text;
- decorative lines crossing text;
- filler agenda or Q&A pages in short decks;
- placeholder images;
- unverified data visualization;
- text walls;
- overusing animation.
## Remaining Human Judgment
Static validation proves protocol shape, not taste. A deck can pass validation and still be visually weak.
Review visual quality separately:
- Does the layout express the slide's logic?
- Does each slide have a clear central claim?
- Are data and claims source-backed?
- Is typography intentional and consistent?
- Is the page readable in a browser at expected size?
- Does the deck vary composition while staying in one visual system?
## Source Coverage
- Covers manifest sections: deck_design_reference_catalog
- Coverage mode: preserve visual quality rules and examples as generation guidance; do not collapse them into generic style advice.

View File

@@ -0,0 +1,98 @@
# SVG Slides Workflow
## Layer Boundary
This workflow owns local SVG deck generation and local validation. It does not call live APIs, choose a PPE lane, or prove backend acceptance of the generated payload.
The output is a local publish-ready bundle: standalone SVG slide files, source notes, optional assets, validation receipts, and a manifest. It is not published.
## Phase 1: Understand Request
Decide whether the user wants a new deck, a continuation of an existing deck, a repair pass, or a visual-quality pass. Clarify only when the target file, target slides, audience, delivery mode, or requested outcome is genuinely ambiguous.
Audience means the final viewer, not the creator. A specific audience can drive density and evidence style directly. Generic labels such as "users", "clients", or "team" are not specific enough for broad generation unless the user asks not to be interrupted.
## Phase 2: Settle Goal Audience Delivery
Settle three values before designing slides:
- `goal`: what the presentation should make the viewer understand or decide.
- `audience`: who will read or watch it.
- `delivery_mode`: `presented` when a speaker talks over it, `self_read` when it must stand alone.
Do not ask the user to pick tone, palette, density, or style. Those are inferred later by the design brief.
## Phase 3: Build Source Material
Broad topic-only requests require real source material. Search snippets, memory, and internal knowledge are not enough.
Collect full source text before drafting claims. Save a local research file with data points, claims, caveats, and source references. Every important claim or number used later must be traceable from `slide_content.md` back to this source material.
## Phase 4: Resolve Design Brief
Create a design brief after goal, audience, delivery mode, language, page count, and source material are known.
The brief must include:
- `narrative_spine`: the sequence logic and discipline of the deck.
- `depth`: altitude, density, include/exclude rules, and main points per slide.
- `tone`: writing and evidence style.
- `visual_system`: color, typography, layout, imagery, material, and decoration direction.
The design brief is authoritative for the generated deck. Do not override it with generic taste while authoring pages.
## Phase 5: Confirm Outline
For broad topics, create an actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise use 8-12 substantive slides for normal decks, unless the user explicitly asked for a short deck.
When the user gave a detailed outline, use it. When the user reorders, removes, adds, or rewrites slides, the user's outline wins over the brief's `narrative_spine`.
## Phase 6: Write slide_content
Write `slide_content.md` before SVG authoring.
`slide_content.md` records the structure, slide roles, key material, data points, claims, quotes, and source references. It does not lock exact final sentences, image paths, chart layout, or final page composition.
## Phase 7: Lock Visual Direction And Plan Visuals
Translate `visual_system` into concrete deck-level style:
- `aesthetic_direction`: the design language and mood from the brief.
- `color_palette`: consistent deck palette, expressed later as `rgb(...)` / `rgba(...)`.
- `typography`: a stable display/body pairing that matches the brief's category and treatment.
- `visual_assets`: per-slide image and chart needs, including aspect ratio and placement intent.
Unless the user explicitly requests no images, cover, section divider, and closing pages default to a high-impact hero image or generated visual. Record the intended asset, crop/aspect ratio, placement, and text-readability overlay treatment in `visual_assets`; do not leave these page roles as text-only by default.
Plan charts before writing slides. Any real quantitative series that supports a slide's point should use the chart workflow rather than a hand-drawn fake chart.
## Phase 8: Author SVG Pages
Each page is a complete standalone SVG document. Compose freely for the page's content logic. Do not stamp out a fixed template pattern.
For each page, record authoring intent before writing:
- the central idea;
- the layout relationship being encoded;
- visual assets used;
- animation decision, or `static`;
- expected validation risks.
Do not regenerate the whole deck structure after slide files have been authored. Add or remove pages through the existing-deck workflow.
## Output Bundle
The local bundle should contain:
- `slides/*.svg`: one standalone SVG slide per page;
- `slide_content.md`: source-backed content plan;
- `research_notes.md` when source material was gathered;
- `resources/` for chart/image sidecars;
- `manifest.json` from `svg_slides_bundle.mjs`;
- `receipts/validate_svg_deck.json` from the static validator;
- optional browser text-boundary receipt.
## Source Coverage
- Covers manifest sections: system_prompt_workflow, slide_outline_tool, activate_slides_edit_tool, incremental_processing
- Coverage mode: preserve workflow semantics from the source while replacing product-specific tool names with local generation stages.

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
function fail(message, code = 2) {
console.error(message);
process.exit(code);
}
const args = process.argv.slice(2);
const targetArg = args.find((arg) => !arg.startsWith("--"));
const outIndex = args.indexOf("--out");
const outPath = outIndex >= 0 ? args[outIndex + 1] : "";
if (!targetArg) {
fail("Usage: node skills/lark-slides/scripts/svg_slides_browser_text_bounds.mjs <deck-dir-or-slides-dir> [--out <json-path>]");
}
let chromium;
try {
({ chromium } = await import("playwright"));
} catch {
fail("playwright is not installed; install it in a dev environment before browser text-boundary QA", 2);
}
const target = path.resolve(targetArg);
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
if (!fs.existsSync(slidesDir)) {
fail(`Slides directory not found: ${slidesDir}`);
}
const slideFiles = fs.readdirSync(slidesDir).filter((file) => file.endsWith(".svg")).sort();
if (!slideFiles.length) {
fail(`No .svg files found in ${slidesDir}`);
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 960, height: 540 }, deviceScaleFactor: 1 });
const results = [];
for (const file of slideFiles) {
const abs = path.join(slidesDir, file);
const svg = fs.readFileSync(abs, "utf8");
await page.setContent(`<!doctype html><html><body style="margin:0">${svg}</body></html>`, { waitUntil: "load" });
const problems = await page.evaluate(() => {
return [...document.querySelectorAll("foreignObject")].flatMap((node, index) => {
if (node.getAttribute("slide:role") !== "shape" || node.getAttribute("slide:shape-type") !== "text") {
return [];
}
const box = node.getBoundingClientRect();
const children = [...node.children];
if (!children.length) {
return [{ index: index + 1, reason: "empty_text_object" }];
}
return children.map((child) => {
const childBox = child.getBoundingClientRect();
const overflowX = childBox.left < box.left - 0.5 || childBox.right > box.right + 0.5;
const overflowY = childBox.top < box.top - 0.5 || childBox.bottom > box.bottom + 0.5;
if (!overflowX && !overflowY) return null;
return {
index: index + 1,
reason: "text_bounds_overflow",
box: { x: box.x, y: box.y, width: box.width, height: box.height },
childBox: { x: childBox.x, y: childBox.y, width: childBox.width, height: childBox.height }
};
}).filter(Boolean);
});
});
results.push({ file: path.relative(process.cwd(), abs), problemCount: problems.length, problems });
}
await browser.close();
const problemCount = results.reduce((sum, item) => sum + item.problemCount, 0);
const report = { status: problemCount === 0 ? "passed" : "failed", problemCount, results };
const json = `${JSON.stringify(report, null, 2)}\n`;
if (outPath) {
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
fs.writeFileSync(outPath, json);
}
process.stdout.write(json);
process.exit(problemCount === 0 ? 0 : 1);

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
function fail(message, code = 2) {
console.error(message);
process.exit(code);
}
const args = process.argv.slice(2);
const deckArg = args.find((arg) => !arg.startsWith("--"));
const titleIndex = args.indexOf("--title");
const title = titleIndex >= 0 ? args[titleIndex + 1] : "";
if (!deckArg || !title) {
fail("Usage: node skills/lark-slides/scripts/svg_slides_bundle.mjs <deck-dir> --title <title>");
}
const root = path.resolve(deckArg);
const slidesDir = fs.existsSync(path.join(root, "slides")) ? path.join(root, "slides") : root;
if (!fs.existsSync(slidesDir)) {
fail(`Slides directory not found: ${slidesDir}`);
}
const validator = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
const validate = spawnSync("node", [validator, root, "--json"], { encoding: "utf8" });
if (!validate.stdout.trim()) {
process.stderr.write(validate.stderr);
process.exit(validate.status || 1);
}
const receipt = JSON.parse(validate.stdout);
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`);
if (receipt.totalErrors !== 0) {
fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1);
}
const slideFiles = fs.readdirSync(slidesDir)
.filter((file) => file.endsWith(".svg"))
.sort();
const pages = slideFiles.map((file, index) => {
const abs = path.join(slidesDir, file);
const raw = fs.readFileSync(abs);
return {
id: path.basename(file, ".svg"),
index: index + 1,
file: path.relative(root, abs).split(path.sep).join("/"),
sha256: crypto.createHash("sha256").update(raw).digest("hex")
};
});
const manifest = {
version: "svglide.manifest.v1",
protocol: "svg-slides.v1",
title,
size: { width: 960, height: 540 },
publish_ready: true,
published: false,
pages,
receipts: {
validate_svg_deck: "receipts/validate_svg_deck.json"
}
};
fs.writeFileSync(path.join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(JSON.stringify({ ok: true, manifest: path.join(root, "manifest.json"), pages: pages.length }, null, 2));

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const script = path.resolve("skills/lark-slides/scripts/svg_slides_bundle.mjs");
function tempDeck() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-bundle-"));
fs.mkdirSync(path.join(root, "slides"));
return root;
}
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="bundle_slide" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Bundle</p>
</foreignObject>
</svg>`;
test("bundle builder writes manifest and validation receipt", () => {
const root = tempDeck();
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide);
const result = spawnSync("node", [script, root, "--title", "Bundle Test"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || result.stdout);
const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8"));
assert.equal(manifest.version, "svglide.manifest.v1");
assert.equal(manifest.protocol, "svg-slides.v1");
assert.equal(manifest.title, "Bundle Test");
assert.deepEqual(manifest.size, { width: 960, height: 540 });
assert.equal(manifest.publish_ready, true);
assert.equal(manifest.published, false);
assert.equal(manifest.pages.length, 1);
assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/);
const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8"));
assert.equal(receipt.totalErrors, 0);
});
test("bundle builder rejects invalid SVG deck", () => {
const root = tempDeck();
fs.writeFileSync(path.join(root, "slides", "slide_01.svg"), validSlide.replace("rgba(255,255,255,1)", "#fff"));
const result = spawnSync("node", [script, root, "--title", "Invalid"], { encoding: "utf8" });
assert.equal(result.status, 1);
assert.match(result.stderr, /not publish-ready/);
assert.equal(fs.existsSync(path.join(root, "receipts", "validate_svg_deck.json")), true);
assert.equal(fs.existsSync(path.join(root, "manifest.json")), false);
});

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const rootArg = process.argv.find((arg) => !arg.startsWith("--") && arg !== process.argv[1] && arg !== process.argv[0]);
const root = path.resolve(rootArg || "skills/lark-slides/references/svg-slides");
const json = process.argv.includes("--json");
const manifestPath = path.join(root, "source", "split-manifest.json");
function readText(file) {
return fs.readFileSync(file, "utf8");
}
function coverageBlock(markdown) {
const lines = markdown.split(/\r?\n/);
const start = lines.findIndex((line) => line.trim() === "## Source Coverage");
if (start === -1) return "";
const block = [];
for (let i = start + 1; i < lines.length; i += 1) {
if (/^#{1,2}\s+/.test(lines[i])) break;
block.push(lines[i]);
}
return block.join("\n");
}
function coverageIds(block) {
const match = block.match(/^- Covers manifest sections:\s*(.+)$/m);
if (!match) return [];
return match[1].split(",").map((value) => value.trim()).filter(Boolean);
}
const errors = [];
if (!fs.existsSync(manifestPath)) {
errors.push(`missing manifest: ${manifestPath}`);
}
const manifest = errors.length === 0 ? JSON.parse(readText(manifestPath)) : { sections: [] };
const sectionsById = new Map(manifest.sections.map((section) => [section.id, section]));
const seen = new Map();
if (fs.existsSync(root)) {
for (const entry of fs.readdirSync(root)) {
if (!entry.endsWith(".md")) continue;
const filePath = path.join(root, entry);
const block = coverageBlock(readText(filePath));
if (!block) {
errors.push(`${entry}: missing ## Source Coverage`);
continue;
}
const ids = coverageIds(block);
if (ids.length === 0) {
errors.push(`${entry}: missing "- Covers manifest sections:" line`);
continue;
}
for (const id of ids) {
const section = sectionsById.get(id);
if (!section) {
errors.push(`${entry}: unknown manifest section "${id}"`);
continue;
}
if (section.target !== entry) {
errors.push(`${entry}: section "${id}" belongs to ${section.target}`);
}
const files = seen.get(id) || [];
files.push(entry);
seen.set(id, files);
}
}
}
for (const section of manifest.sections) {
const files = seen.get(section.id) || [];
if (files.length === 0) {
errors.push(`${section.id}: not covered by ${section.target}`);
}
if (files.length > 1) {
errors.push(`${section.id}: covered multiple times by ${files.join(", ")}`);
}
}
const report = {
root,
manifest: manifestPath,
sectionCount: manifest.sections.length,
coveredCount: seen.size,
errors
};
if (json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} else if (errors.length === 0) {
console.log(`Source coverage OK: ${report.coveredCount}/${report.sectionCount} sections`);
} else {
console.error(`Source coverage failed: ${errors.length} errors`);
for (const error of errors) console.error(`- ${error}`);
}
process.exit(errors.length === 0 ? 0 : 1);

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const script = path.resolve("skills/lark-slides/scripts/svg_slides_source_coverage_check.mjs");
function tempRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-coverage-"));
fs.mkdirSync(path.join(root, "source"), { recursive: true });
return root;
}
function writeManifest(root, sections) {
fs.writeFileSync(path.join(root, "source", "split-manifest.json"), JSON.stringify({
version: "svg-slides.split-manifest.v1",
source: "source/full.debranded.md",
sections
}, null, 2));
}
function run(root) {
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
}
test("passes when each manifest section is covered by its target file", () => {
const root = tempRoot();
writeManifest(root, [{ id: "workflow", lines: [1, 10], target: "workflow.md" }]);
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: workflow\n");
const result = run(root);
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).errors.length, 0);
});
test("fails when a section is missing from Source Coverage", () => {
const root = tempRoot();
writeManifest(root, [{ id: "protocol", lines: [1, 10], target: "protocol.md" }]);
fs.writeFileSync(path.join(root, "protocol.md"), "# Protocol\n\n## Source Coverage\n\n- Covers manifest sections: other\n");
const result = run(root);
assert.equal(result.status, 1);
const report = JSON.parse(result.stdout);
assert.ok(report.errors.some((error) => error.includes("unknown manifest section")));
assert.ok(report.errors.some((error) => error.includes("not covered")));
});
test("fails when a section is declared by the wrong target file", () => {
const root = tempRoot();
writeManifest(root, [{ id: "visual", lines: [1, 10], target: "visual-design.md" }]);
fs.writeFileSync(path.join(root, "workflow.md"), "# Workflow\n\n## Source Coverage\n\n- Covers manifest sections: visual\n");
const result = run(root);
assert.equal(result.status, 1);
assert.ok(JSON.parse(result.stdout).errors.some((error) => error.includes("belongs to visual-design.md")));
});

View File

@@ -0,0 +1,244 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
function usage() {
console.error("Usage: node skills/lark-slides/scripts/validate_svg_deck.mjs <deck-dir-or-slides-dir> [--json]");
}
const args = process.argv.slice(2);
const json = args.includes("--json");
const targetArg = args.find((arg) => !arg.startsWith("--"));
if (!targetArg) {
usage();
process.exit(2);
}
const target = path.resolve(targetArg);
const slidesDir = fs.existsSync(path.join(target, "slides")) ? path.join(target, "slides") : target;
if (!fs.existsSync(slidesDir)) {
console.error(`Slides directory not found: ${slidesDir}`);
process.exit(2);
}
const slideFiles = fs.readdirSync(slidesDir)
.filter((file) => file.endsWith(".svg"))
.sort()
.map((file) => path.join(slidesDir, file));
if (!slideFiles.length) {
console.error(`No .svg files found in: ${slidesDir}`);
process.exit(2);
}
function commandExists(name) {
const result = spawnSync("sh", ["-lc", `command -v ${name}`], { encoding: "utf8" });
return result.status === 0;
}
function checkXml(file, errors) {
if (!commandExists("xmllint")) {
errors.push({
rule: "xml.valid",
severity: "warn",
message: "xmllint is unavailable; XML parser check skipped",
});
return;
}
const result = spawnSync("xmllint", ["--noout", file], { encoding: "utf8" });
if (result.status !== 0) {
errors.push({
rule: "xml.valid",
severity: "error",
message: (result.stderr || result.stdout || "xmllint failed").trim(),
});
}
}
function firstElementAfterDefs(svg) {
const rootOpen = svg.match(/<svg\b[^>]*>/);
if (!rootOpen) return null;
let inner = svg.slice(rootOpen.index + rootOpen[0].length, svg.lastIndexOf("</svg>")).trim();
if (inner.startsWith("<defs")) {
const end = inner.indexOf("</defs>");
if (end === -1) return null;
inner = inner.slice(end + "</defs>".length).trim();
}
return inner.match(/^<([a-zA-Z][\w:-]*)\b([^>]*)>/)?.[0] || null;
}
function stripDefs(svg) {
return svg.replace(/<defs\b[\s\S]*?<\/defs>/g, "");
}
function attrValue(tag, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return tag.match(new RegExp(`${escaped}="([^"]*)"`))?.[1] || "";
}
function isColorValueAllowed(value) {
return /^(rgba?\([^)]*\)|url\(#[-\w]+\))$/.test(value.trim());
}
function checkSlide(file) {
const rel = path.relative(process.cwd(), file);
const svg = fs.readFileSync(file, "utf8");
const errors = [];
checkXml(file, errors);
const root = svg.match(/<svg\b[^>]*>/)?.[0] || "";
if (!root) {
errors.push({ rule: "svg.root", severity: "error", message: "missing <svg> root" });
} else {
if (!/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/.test(root)) {
errors.push({ rule: "svg.root.xmlns", severity: "error", message: "missing SVG namespace" });
}
if (!/xmlns:slide="https:\/\/slides\.bytedance\.com\/ns"/.test(root)) {
errors.push({ rule: "svg.root.slide-xmlns", severity: "error", message: "missing slide namespace" });
}
if (!/slide:role="slide"/.test(root)) {
errors.push({ rule: "svg.root.slide-role", severity: "error", message: "root must have slide:role=\"slide\"" });
}
if (!/id="[^"]+"/.test(root)) {
errors.push({ rule: "svg.root.id", severity: "error", message: "root must have id" });
}
if (!/viewBox="0 0 960 540"/.test(root)) {
errors.push({ rule: "svg.root.viewBox", severity: "error", message: "expected viewBox=\"0 0 960 540\"" });
}
}
if (/<presentation\b/.test(svg)) {
errors.push({ rule: "svg.no-presentation-wrapper", severity: "error", message: "single slide file must not wrap with <presentation>" });
}
const first = firstElementAfterDefs(svg);
const backgroundCount = (svg.match(/slide:role="background"/g) || []).length;
if (backgroundCount !== 1) {
errors.push({ rule: "background.count", severity: "error", message: `expected exactly one background, found ${backgroundCount}` });
}
if (!first || !/^(<rect\b|<image\b)/.test(first) || !/slide:role="background"/.test(first)) {
errors.push({ rule: "background.first-child", severity: "error", message: "first rendered child after optional <defs> must be the background" });
}
const bodyNoDefs = stripDefs(svg);
const forbidden = [
{ rule: "forbid.style-block", re: /<style\b/, message: "slide SVG must not rely on <style> blocks" },
{ rule: "forbid.css-class", re: /\bclass="/, message: "slide SVG must not rely on CSS classes" },
{ rule: "forbid.div-wrapper", re: /<div\b/, message: "text foreignObject must not contain <div>" },
{ rule: "forbid.section-wrapper", re: /<section\b/, message: "text foreignObject must not contain <section>" },
{ rule: "forbid.svg-text", re: /<text\b/, message: "use foreignObject rich text, not SVG <text>" },
{ rule: "forbid.svg-marker", re: /\bmarker-(start|end|mid)=|<marker\b/, message: "line arrowheads must use slide:* arrow attributes, not SVG marker" },
{ rule: "forbid.legacy-fontSize", re: /\bfontSize="/, message: "text visual properties must be in style, not legacy fontSize attribute" },
{ rule: "forbid.legacy-bold", re: /\bbold="/, message: "text visual properties must be in style, not legacy bold attribute" },
];
for (const item of forbidden) {
if (item.re.test(bodyNoDefs)) {
errors.push({ rule: item.rule, severity: "error", message: item.message });
}
}
for (const match of bodyNoDefs.matchAll(/\b(fill|stroke|stop-color)="([^"]+)"/g)) {
const [, attr, value] = match;
if (!isColorValueAllowed(value)) {
errors.push({
rule: "color.attr",
severity: "error",
message: `${attr} must be rgb(...), rgba(...), or url(#...); got ${JSON.stringify(value)}`,
});
}
}
for (const match of bodyNoDefs.matchAll(/(?:^|;)\s*color\s*:\s*([^;"]+)/g)) {
const value = match[1].trim();
if (!/^rgba?\([^)]*\)$/.test(value)) {
errors.push({
rule: "color.css",
severity: "error",
message: `CSS color must be rgb(...) or rgba(...); got ${JSON.stringify(value)}`,
});
}
}
const foreignObjects = [...svg.matchAll(/<foreignObject\b([^>]*)>([\s\S]*?)<\/foreignObject>/g)];
for (const [index, match] of foreignObjects.entries()) {
const attrText = match[1];
const inner = match[2].trim();
const label = `foreignObject #${index + 1}`;
const isTextObject = /slide:role="shape"/.test(attrText) && /slide:shape-type="text"/.test(attrText);
if (!isTextObject) continue;
for (const attr of ["x", "y", "width", "height"]) {
if (!new RegExp(`\\b${attr}="[-0-9.]+`).test(attrText)) {
errors.push({ rule: "text.geometry", severity: "error", message: `${label} missing numeric ${attr}` });
}
}
const styleText = attrValue(match[0], "style");
if (!/font-size:\s*\d+(?:\.\d+)?px/.test(styleText)) {
errors.push({ rule: "text.style.font-size", severity: "error", message: `${label} missing font-size with px suffix in style` });
}
if (!/color:\s*rgba?\(/.test(styleText)) {
errors.push({ rule: "text.style.color", severity: "error", message: `${label} missing rgb/rgba color in style` });
}
if (!/^<(p|ul|ol|h1|h2|h3|small)\b/.test(inner)) {
errors.push({
rule: "text.direct-child",
severity: "error",
message: `${label} first direct child must be p/ul/ol/h1/h2/h3/small, got ${inner.slice(0, 40) || "empty"}`,
});
}
if (/<(div|section)\b/.test(inner)) {
errors.push({ rule: "text.no-wrapper", severity: "error", message: `${label} contains an invalid wrapper element` });
}
if (/^([^<]|\s)+$/.test(inner)) {
errors.push({ rule: "text.no-bare-text", severity: "error", message: `${label} contains bare text instead of xhtml children` });
}
}
for (const match of bodyNoDefs.matchAll(/<line\b([^>]*)>/g)) {
const attrs = match[1];
if (!/slide:role="shape"/.test(attrs) || !/slide:shape-type="line"/.test(attrs)) {
errors.push({ rule: "line.role", severity: "error", message: "line must carry slide:role=\"shape\" and slide:shape-type=\"line\"" });
}
if (!/\bstroke="rgba?\(/.test(attrs)) {
errors.push({ rule: "line.stroke", severity: "error", message: "line must have rgb/rgba stroke" });
}
}
return { file: rel, errorCount: errors.filter((item) => item.severity === "error").length, errors };
}
const results = slideFiles.map(checkSlide);
const totalErrors = results.reduce((sum, result) => sum + result.errorCount, 0);
const report = {
target: path.relative(process.cwd(), target),
slidesDir: path.relative(process.cwd(), slidesDir),
slideCount: slideFiles.length,
totalErrors,
results,
};
if (json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(`SVG deck validation: ${report.target}`);
console.log(`Slides: ${report.slideCount}`);
console.log(`Errors: ${report.totalErrors}`);
for (const result of results) {
const status = result.errorCount ? "FAIL" : "PASS";
console.log(`\n[${status}] ${result.file}`);
for (const error of result.errors) {
if (error.severity === "warn") {
console.log(` WARN ${error.rule}: ${error.message}`);
} else {
console.log(` ${error.rule}: ${error.message}`);
}
}
}
}
process.exit(totalErrors ? 1 : 0);

View File

@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const script = path.resolve("skills/lark-slides/scripts/validate_svg_deck.mjs");
function tempDeck() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "svg-slides-validator-"));
fs.mkdirSync(path.join(root, "slides"));
return root;
}
function writeSlide(root, name, body) {
fs.writeFileSync(path.join(root, "slides", name), body);
}
function runValidator(root) {
return spawnSync("node", [script, root, "--json"], { encoding: "utf8" });
}
const validSlide = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="valid" viewBox="0 0 960 540">
<rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="600" height="80" style="font-size:32px;font-family:DM Sans,PingFang SC,Noto Sans SC,Arial,sans-serif;color:rgba(15,23,42,1);line-height:1.2;letter-spacing:0px;padding:0px">
<p xmlns="http://www.w3.org/1999/xhtml" style="margin:0px;font-size:32px;color:rgba(15,23,42,1)">Valid</p>
</foreignObject>
</svg>`;
test("valid SVG deck passes", () => {
const root = tempDeck();
writeSlide(root, "slide_01.svg", validSlide);
const result = runValidator(root);
assert.equal(result.status, 0, result.stderr);
const report = JSON.parse(result.stdout);
assert.equal(report.slideCount, 1);
assert.equal(report.totalErrors, 0);
});
test("invalid SVG deck reports protocol errors", () => {
const root = tempDeck();
writeSlide(root, "slide_01.svg", `<svg xmlns="http://www.w3.org/2000/svg" id="bad" viewBox="0 0 960 540">
<style>.t{fill:red}</style>
<rect width="960" height="540" fill="#fff"/>
<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="300" height="80" style="font-size:32;color:#111">
<div xmlns="http://www.w3.org/1999/xhtml"><p>Bad</p></div>
</foreignObject>
</svg>`);
const result = runValidator(root);
assert.equal(result.status, 1);
const report = JSON.parse(result.stdout);
const rules = report.results.flatMap((item) => item.errors.map((error) => error.rule));
assert.ok(rules.includes("svg.root.slide-xmlns"));
assert.ok(rules.includes("svg.root.slide-role"));
assert.ok(rules.includes("background.first-child"));
assert.ok(rules.includes("forbid.style-block"));
assert.ok(rules.includes("forbid.div-wrapper"));
assert.ok(rules.includes("color.attr"));
assert.ok(rules.includes("text.style.font-size"));
assert.ok(rules.includes("text.style.color"));
});
test("examples directory remains protocol-valid", () => {
const examples = path.resolve("skills/lark-slides/references/svg-slides/examples");
const result = spawnSync("node", [script, examples, "--json"], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const report = JSON.parse(result.stdout);
assert.equal(report.slideCount, 3);
assert.equal(report.totalErrors, 0);
});

View File

@@ -24,7 +24,17 @@ import (
const EnvBinaryPath = "LARK_CLI_BIN"
const projectRootMarkerDir = "tests"
const cliBinaryName = "lark-cli"
const CleanupTimeout = 30 * time.Second
const (
// CleanupTimeout is the outer teardown budget. Keep it above any
// per-resource wait so cleanup command retries still have room to run.
CleanupTimeout = 60 * time.Second
defaultRetryAttempts = 4
defaultRetryInitialDelay = time.Second
defaultRetryMaxDelay = 6 * time.Second
defaultRetryBackoffMultiple = 2
)
func SkipWithoutUserToken(t *testing.T) {
t.Helper()
@@ -102,6 +112,34 @@ type Result struct {
RunErr error
}
type cleanupWarningError struct {
err error
}
func (e *cleanupWarningError) Error() string {
return e.err.Error()
}
func (e *cleanupWarningError) Unwrap() error {
return e.err
}
// CleanupWarning marks a cleanup verification issue as non-fatal after the
// destructive cleanup command itself has already succeeded.
func CleanupWarning(err error) error {
if err == nil {
return nil
}
return &cleanupWarningError{err: err}
}
// IsCleanupWarning reports whether err should be logged without failing the
// parent test.
func IsCleanupWarning(err error) bool {
var warning *cleanupWarningError
return errors.As(err, &warning)
}
// RetryOptions configures retry behavior for flaky external API calls.
type RetryOptions struct {
Attempts int
@@ -111,8 +149,25 @@ type RetryOptions struct {
ShouldRetry func(*Result) bool
}
// WaitOptions configures a bounded poll loop for eventually consistent cleanup
// or verification checks.
type WaitOptions struct {
Timeout time.Duration
Interval time.Duration
TimeoutError func() error
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
// Service errors that return {"error":{"retryable":true}} are retried with
// bounded exponential backoff so individual tests do not need to remember
// RunCmdWithRetry for normal transient server contention.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
return RunCmdWithRetry(ctx, req, RetryOptions{
ShouldRetry: ResultHasRetryableError,
})
}
func runCmdOnce(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
if err != nil {
return nil, err
@@ -186,16 +241,16 @@ func buildCommandEnv(req Request) []string {
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {
opts.Attempts = 4
opts.Attempts = defaultRetryAttempts
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = 1 * time.Second
opts.InitialDelay = defaultRetryInitialDelay
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = 6 * time.Second
opts.MaxDelay = defaultRetryMaxDelay
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = 2
opts.BackoffMultiple = defaultRetryBackoffMultiple
}
if opts.ShouldRetry == nil {
opts.ShouldRetry = func(result *Result) bool {
@@ -206,7 +261,7 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
delay := opts.InitialDelay
var lastResult *Result
for attempt := 1; attempt <= opts.Attempts; attempt++ {
result, err := RunCmd(ctx, req)
result, err := runCmdOnce(ctx, req)
if err != nil {
return nil, err
}
@@ -234,6 +289,63 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
return lastResult, nil
}
// ResultHasRetryableError reports whether lark-cli returned a structured
// service error with error.retryable=true in either output stream.
func ResultHasRetryableError(result *Result) bool {
if result == nil {
return false
}
return rawHasRetryableError(result.Stdout) || rawHasRetryableError(result.Stderr)
}
func rawHasRetryableError(raw string) bool {
payload := extractJSONPayload(raw)
if payload == "" {
return false
}
return gjson.Get(payload, "error.retryable").Bool()
}
// WaitForCondition polls condition until it returns true, an error, the context
// is canceled, or the configured timeout expires.
func WaitForCondition(ctx context.Context, opts WaitOptions, condition func() (bool, error)) error {
if condition == nil {
return errors.New("wait condition is nil")
}
if opts.Timeout <= 0 {
opts.Timeout = CleanupTimeout
}
if opts.Interval <= 0 {
opts.Interval = time.Second
}
deadline := time.NewTimer(opts.Timeout)
defer deadline.Stop()
ticker := time.NewTicker(opts.Interval)
defer ticker.Stop()
for {
done, err := condition()
if err != nil {
return err
}
if done {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
if opts.TimeoutError != nil {
return opts.TimeoutError()
}
return fmt.Errorf("condition still false after %s", opts.Timeout)
case <-ticker.C:
}
}
}
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
func GenerateSuffix() string {
now := time.Now().UTC()
@@ -251,6 +363,10 @@ func ReportCleanupFailure(parentT *testing.T, prefix string, result *Result, err
parentT.Helper()
if err != nil {
if IsCleanupWarning(err) {
parentT.Logf("%s: %v", prefix, err)
return
}
parentT.Errorf("%s: %v", prefix, err)
return
}
@@ -271,26 +387,11 @@ func isCleanupSuppressedResult(result *Result) bool {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
payload := extractJSONPayload(result.Stdout)
if payload == "" {
payload = extractJSONPayload(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
if payload == "" {
return false
}
@@ -306,6 +407,32 @@ func isCleanupSuppressedResult(result *Result) bool {
return errType == "api_error" && (errCode == 800004135 || strings.Contains(errMessage, " limited"))
}
func extractJSONPayload(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if gjson.Valid(raw) {
return raw
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return ""
}
payload := raw[start:]
if !gjson.Valid(payload) {
return ""
}
return payload
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {

View File

@@ -5,10 +5,12 @@ package clie2e
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -223,6 +225,88 @@ func TestRunCmd(t *testing.T) {
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "2\n", string(countBytes))
})
t.Run("does not retry non-retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"always-non-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestRunCmdWithRetry(t *testing.T) {
t.Run("does not include RunCmd default retry as a nested retry", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmdWithRetry(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
}, RetryOptions{
Attempts: 1,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
ShouldRetry: ResultHasRetryableError,
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestWaitForCondition(t *testing.T) {
t.Run("polls until condition succeeds", func(t *testing.T) {
attempts := 0
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: 50 * time.Millisecond,
Interval: time.Millisecond,
}, func() (bool, error) {
attempts++
return attempts == 2, nil
})
require.NoError(t, err)
assert.Equal(t, 2, attempts)
})
t.Run("returns custom timeout error", func(t *testing.T) {
wantErr := errors.New("still visible")
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: time.Millisecond,
Interval: time.Millisecond,
TimeoutError: func() error { return wantErr },
}, func() (bool, error) {
return false, nil
})
assert.ErrorIs(t, err, wantErr)
})
}
type fakeCLI struct {
@@ -260,6 +344,35 @@ if [ "$1" = "emit-stdin" ]; then
exit 0
fi
if [ "$1" = "fail-once-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
if [ "$count" -eq 1 ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "always-non-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
echo '{"ok":false,"error":{"type":"api","code":123,"message":"validation failed","retryable":false}}' >&2
exit 1
fi
exit_code=0
while [ "$#" -gt 0 ]; do
case "$1" in

View File

@@ -21,7 +21,7 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
package docs
import (
"context"
@@ -23,7 +23,7 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
Args: []string{
"docs", "+fetch",
"--doc", "doxcnDryRunCompat",
"--api-version", "legacy",
"--api-version", "v1",
"--dry-run",
},
DefaultAs: "bot",

View File

@@ -14,6 +14,18 @@ import (
"github.com/tidwall/gjson"
)
const (
driveDeleteVisibilityTimeout = 30 * time.Second
driveDeleteVisibilityPoll = 3 * time.Second
)
var driveDeleteVisibilityWait = clie2e.WaitOptions{
// This wait only covers the post-delete visibility lag after Drive accepts
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
Timeout: driveDeleteVisibilityTimeout,
Interval: driveDeleteVisibilityPoll,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
@@ -60,14 +72,18 @@ func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
// returned a suppressed not_found or partial API error but the resource still
// exists.
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
}
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
if defaultAs == "" {
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{})
})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}
@@ -82,35 +98,21 @@ func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
}
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
if err := WaitDriveResourceDeleted(ctx, token, docType, defaultAs); err != nil {
return deleteResult, err
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
return deleteResult, clie2e.CleanupWarning(
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
)
}
return deleteResult, nil
}
func WaitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) error {
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
deleted, err := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
if err != nil {
return err
}
if deleted {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("drive resource %s/%s still exists after delete", docType, token)
case <-ticker.C:
}
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
opts.TimeoutError = func() error {
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
})
}
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {

View File

@@ -5,8 +5,13 @@ package drive
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"
)
@@ -16,3 +21,59 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
require.NotEmpty(t, folderToken)
return folderToken
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
Timeout: 10 * time.Millisecond,
Interval: time.Millisecond,
})
require.NotNil(t, result)
assert.Equal(t, 0, result.ExitCode)
require.Error(t, err)
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
})
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
require.NotNil(t, result)
assert.Equal(t, 1, result.ExitCode)
require.Error(t, err)
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
assert.Contains(t, err.Error(), "still exists after delete failed")
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi
echo "unexpected fake CLI args: $*" >&2
exit 2
`
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return binaryPath
}

View File

@@ -1,9 +1,9 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
- Denominator: 65 leaf commands
- Covered: 16
- Coverage: 24.6%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
@@ -20,6 +20,8 @@
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |

View File

@@ -5,6 +5,7 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"testing"
@@ -203,6 +204,11 @@ type wikiNodeInfo struct {
ObjType string
}
const (
wikiDeleteVisibilityTimeout = 30 * time.Second
wikiDeleteVisibilityPoll = 3 * time.Second
)
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
// original node token is gone. Wiki cleanup cannot use drive +delete because
// wiki origin nodes need the backing obj_token and parent nodes must delete
@@ -333,28 +339,34 @@ func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string)
}
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
deadline := time.NewTimer(20 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
var lastTransientErr error
for {
opts := clie2e.WaitOptions{
Timeout: wikiDeleteVisibilityTimeout,
Interval: wikiDeleteVisibilityPoll,
TimeoutError: func() error {
if lastTransientErr != nil {
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
}
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
},
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
if err != nil {
return err
if isWikiVerifyTransientError(err) {
lastTransientErr = err
return false, nil
} else {
return false, err
}
}
if deleted {
return nil
return true, nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
case <-ticker.C:
}
}
return false, nil
})
}
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
@@ -375,9 +387,31 @@ func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
if isWikiNodeDeletedResult(result) {
return true, nil
}
if isWikiVerifyTransientResult(result) {
return false, wikiVerifyTransientError{
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
}
}
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
}
type wikiVerifyTransientError struct {
err error
}
func (e wikiVerifyTransientError) Error() string {
return e.err.Error()
}
func (e wikiVerifyTransientError) Unwrap() error {
return e.err
}
func isWikiVerifyTransientError(err error) bool {
var transient wikiVerifyTransientError
return err != nil && errors.As(err, &transient)
}
func wikiAPISuccess(stdout string) bool {
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
return ok.Bool()
@@ -404,6 +438,55 @@ func isWikiNodeDeletedResult(result *clie2e.Result) bool {
strings.Contains(combined, "not found")
}
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
if result == nil {
return false
}
payload := result.Stdout
if strings.TrimSpace(payload) == "" {
payload = result.Stderr
}
if gjson.Get(payload, "error.type").String() != "internal" ||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
return false
}
message := strings.ToLower(gjson.Get(payload, "error.message").String())
return strings.Contains(message, "http 429") ||
strings.Contains(message, "http 500") ||
strings.Contains(message, "http 502") ||
strings.Contains(message, "http 503") ||
strings.Contains(message, "http 504")
}
func TestWikiVerifyTransientResult(t *testing.T) {
t.Run("matches invalid response from transient http status", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
}
require.True(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match unrelated invalid response", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match api errors", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
}
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
t.Helper()