diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39fabf865..41eaa9fc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Makefile b/Makefile index d401cd173..aad7eccf2 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index d793db419..bfd1d126e 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -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 { diff --git a/scripts/ci-workflow.test.sh b/scripts/ci-workflow.test.sh index b8bdcd989..1ff3bf31b 100644 --- a/scripts/ci-workflow.test.sh +++ b/scripts/ci-workflow.test.sh @@ -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 diff --git a/scripts/domain-map.js b/scripts/domain-map.js new file mode 100644 index 000000000..abdbcd368 --- /dev/null +++ b/scripts/domain-map.js @@ -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, +}; diff --git a/scripts/domain-map.json b/scripts/domain-map.json new file mode 100644 index 000000000..e7e37fd22 --- /dev/null +++ b/scripts/domain-map.json @@ -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" + ] +} diff --git a/scripts/e2e_domains.js b/scripts/e2e_domains.js new file mode 100644 index 000000000..26c8305ea --- /dev/null +++ b/scripts/e2e_domains.js @@ -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, +}; diff --git a/scripts/e2e_domains.test.js b/scripts/e2e_domains.test.js new file mode 100644 index 000000000..5657d7f5d --- /dev/null +++ b/scripts/e2e_domains.test.js @@ -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/); +}); diff --git a/scripts/pr-labels/index.js b/scripts/pr-labels/index.js index 01b6428ae..0abc8f905 100755 --- a/scripts/pr-labels/index.js +++ b/scripts/pr-labels/index.js @@ -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) { diff --git a/scripts/resolve-changed-from.test.sh b/scripts/resolve-changed-from.test.sh index dfe8d71ad..9aba01afa 100644 --- a/scripts/resolve-changed-from.test.sh +++ b/scripts/resolve-changed-from.test.sh @@ -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() { diff --git a/tests/cli_e2e/core.go b/tests/cli_e2e/core.go index 2a5f62cb0..adc296234 100644 --- a/tests/cli_e2e/core.go +++ b/tests/cli_e2e/core.go @@ -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 != "" { diff --git a/tests/cli_e2e/core_test.go b/tests/cli_e2e/core_test.go index 803d0ee74..853872ee9 100644 --- a/tests/cli_e2e/core_test.go +++ b/tests/cli_e2e/core_test.go @@ -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 diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index b3b2791db..77e243c17 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -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 `; `--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 `; `--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` | diff --git a/tests/cli_e2e/doc/docs_dryrun_test.go b/tests/cli_e2e/docs/docs_fetch_dryrun_test.go similarity index 97% rename from tests/cli_e2e/doc/docs_dryrun_test.go rename to tests/cli_e2e/docs/docs_fetch_dryrun_test.go index 02fb0ac28..c1acda357 100644 --- a/tests/cli_e2e/doc/docs_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_fetch_dryrun_test.go @@ -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", diff --git a/tests/cli_e2e/drive/helpers.go b/tests/cli_e2e/drive/helpers.go index 1cf1fa734..86ee49ff8 100644 --- a/tests/cli_e2e/drive/helpers.go +++ b/tests/cli_e2e/drive/helpers.go @@ -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) { diff --git a/tests/cli_e2e/drive/helpers_test.go b/tests/cli_e2e/drive/helpers_test.go index 75249126a..d118c1fe9 100644 --- a/tests/cli_e2e/drive/helpers_test.go +++ b/tests/cli_e2e/drive/helpers_test.go @@ -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 +} diff --git a/tests/cli_e2e/wiki/helpers_test.go b/tests/cli_e2e/wiki/helpers_test.go index 318a013e5..d711c8252 100644 --- a/tests/cli_e2e/wiki/helpers_test.go +++ b/tests/cli_e2e/wiki/helpers_test.go @@ -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()