diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml new file mode 100644 index 00000000..f8dcfd82 --- /dev/null +++ b/.github/workflows/cli-e2e.yml @@ -0,0 +1,135 @@ +name: CLI E2E Tests + +on: + push: + branches: [main] + paths: + - "**.go" + - go.mod + - go.sum + - Makefile + - scripts/fetch_meta.py + - tests/cli_e2e/** + - .github/workflows/cli-e2e.yml + pull_request: + branches: [main] + paths: + - "**.go" + - go.mod + - go.sum + - Makefile + - scripts/fetch_meta.py + - tests/cli_e2e/** + - .github/workflows/cli-e2e.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + cli-e2e: + # Forked pull_request runs do not receive repository/org secrets except GITHUB_TOKEN. + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} + runs-on: ubuntu-latest + env: + TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }} + TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }} + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.x' + + - name: Build lark-cli + run: make build + + - name: Configure bot credentials + 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" + exit 1 + fi + printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin + + - name: Run CLI E2E tests + env: + LARK_CLI_BIN: ${{ github.workspace }}/lark-cli + run: | + packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$') + if [ -z "$packages" ]; then + echo "No CLI E2E packages to test after exclusions." + exit 1 + fi + go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages + + - name: Summarize CLI E2E test report + if: ${{ !cancelled() }} + run: | + python3 - <<'PY' + import os + import xml.etree.ElementTree as ET + + report_path = "cli-e2e-report.xml" + summary_path = os.environ["GITHUB_STEP_SUMMARY"] + + root = ET.parse(report_path).getroot() + suites = [root] if root.tag == "testsuite" else root.findall("testsuite") + + tests = failures = errors = skipped = 0 + failed_cases = [] + skipped_cases = [] + + for suite in suites: + tests += int(suite.attrib.get("tests", 0)) + failures += int(suite.attrib.get("failures", 0)) + errors += int(suite.attrib.get("errors", 0)) + skipped += int(suite.attrib.get("skipped", 0)) + + for case in suite.findall("testcase"): + classname = case.attrib.get("classname", "") + name = case.attrib.get("name", "") + label = f"{classname}.{name}" if classname else name + + failure = case.find("failure") + error = case.find("error") + skipped_node = case.find("skipped") + + if failure is not None or error is not None: + message = "" + node = failure if failure is not None else error + if node is not None: + message = node.attrib.get("message", "") or (node.text or "").strip() + failed_cases.append((label, message)) + elif skipped_node is not None: + message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip() + skipped_cases.append((label, message)) + + passed = tests - failures - errors - skipped + + with open(summary_path, "a", encoding="utf-8") as f: + f.write("## CLI E2E Test Report\n\n") + f.write(f"- Total: {tests}\n") + f.write(f"- Passed: {passed}\n") + f.write(f"- Failed: {failures}\n") + f.write(f"- Errors: {errors}\n") + f.write(f"- Skipped: {skipped}\n\n") + + if failed_cases: + f.write("### Failed Tests\n\n") + for label, message in failed_cases: + detail = f" - {message}" if message else "" + f.write(f"- `{label}`{detail}\n") + f.write("\n") + + if skipped_cases: + f.write("### Skipped Tests\n\n") + for label, message in skipped_cases: + detail = f" - {message}" if message else "" + f.write(f"- `{label}`{detail}\n") + f.write("\n") + PY diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 351cf3d9..921bf1e8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "**.go" + - "!tests/cli_e2e/**" - go.mod - go.sum - .github/workflows/coverage.yml @@ -12,6 +13,7 @@ on: branches: [main] paths: - "**.go" + - "!tests/cli_e2e/**" - go.mod - go.sum - .github/workflows/coverage.yml @@ -37,7 +39,9 @@ jobs: run: python3 scripts/fetch_meta.py - name: Run tests with coverage - run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + run: | + packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') + go test -race -coverprofile=coverage.txt -covermode=atomic $packages - name: Generate coverage report run: | diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 1506b56e..d44b5d6a 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 diff --git a/go.mod b/go.mod index ed41d1f0..c9b2686c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 github.com/zalando/go-keyring v0.2.8 golang.org/x/net v0.33.0 golang.org/x/sys v0.33.0 @@ -30,6 +32,7 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect @@ -46,9 +49,13 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/smarty/assertions v1.15.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b6a807a3..f5838cab 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,12 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/tests/cli_e2e/README.md b/tests/cli_e2e/README.md new file mode 100644 index 00000000..28055cd1 --- /dev/null +++ b/tests/cli_e2e/README.md @@ -0,0 +1,35 @@ +# CLI E2E Tests + +This directory contains end-to-end tests for `lark-cli`. + +The purpose of this module is to verify real CLI workflows from a user-facing perspective: run the compiled binary, execute commands end to end, and catch regressions that are not obvious from unit tests alone. + +## What Is Here + +- `core.go`, `core_test.go`: the shared E2E test harness and its own tests +- `demo/`: reference testcase(s) +- `cli-e2e-testcase-writer/`: the local skill for adding or updating testcase files in this module + +## For Contributors + +When writing or updating testcases under `tests/cli_e2e`, install and use this skill first: + +```bash +npx skills add ./tests/cli_e2e/cli-e2e-testcase-writer +``` + +Then follow `tests/cli_e2e/cli-e2e-testcase-writer/SKILL.md`. + +Example prompt: + +```text +Use $cli-e2e-testcase-writer to write lark-cli xxx domain related testcases. +Put them under tests/cli_e2e/xxx. +``` + +## Run + +```bash +make build +go test ./tests/cli_e2e/... -count=1 +``` diff --git a/tests/cli_e2e/cli-e2e-testcase-writer/SKILL.md b/tests/cli_e2e/cli-e2e-testcase-writer/SKILL.md new file mode 100644 index 00000000..7b0e3335 --- /dev/null +++ b/tests/cli_e2e/cli-e2e-testcase-writer/SKILL.md @@ -0,0 +1,218 @@ +--- +name: cli-e2e-testcase-writer +description: Write scenario-based end-to-end Go testcases for the compiled `lark-cli` binary under `tests/cli_e2e`. Use when adding or updating a CLI testcase that should autonomously explore help and schema output, build a self-contained lifecycle with `clie2e.RunCmd`, organize steps with `t.Run`, clean up with `t.Cleanup`, and assert JSON output with `testify/assert` and `gjson`. +metadata: + requires: + bins: ["lark-cli"] +--- + +# CLI E2E Testcase Writer + +Write testcase code, not framework code. `tests/cli_e2e/core.go` already provides the harness, and `tests/cli_e2e/demo/task_lifecycle_test.go` is the reference example only. Unless the user explicitly asks for framework work, add or update testcase files only. + +## What a good testcase looks like + +A good cli e2e testcase here is: +- scenario-based, not a loose smoke test +- self-contained and data-consistent + create the resource you later read, update, search, or delete +- broad enough to prove the workflow + usually create plus one or more follow-up reads or mutations plus teardown +- scoped to one feature or one workflow + do not turn one testcase into the entire domain +- written with normal Go testing primitives + +This is different from traditional API test suites where usage docs live elsewhere. Here, the command contract is discoverable from `lark-cli --help`, domain help, subcommand help, and schema output, and the agent is expected to explore and verify it autonomously. + +## File organization + +Put real domain testcases under: + +```text +tests/cli_e2e/{domain}/ +``` + +Examples: +- `tests/cli_e2e/task/task_status_workflow_test.go` +- `tests/cli_e2e/task/task_comment_workflow_test.go` + +Treat `tests/cli_e2e/demo/` as reference material, not as the place to accumulate real coverage. + +## How to split cases + +Split by feature or workflow, not by API surface inventory. + +Good splits: +- one file for task status flow: `create -> complete -> get -> reopen -> get` +- one file for task comment flow +- one file for task reminder flow +- one file for tasklist association flow + +Bad split: +- one giant `task_test.go` that creates a task, updates it, comments it, reminds it, assigns it, adds followers, attaches tasklists, and queries everything in one lifecycle + +Prefer: +- one top-level test per workflow +- one file per workflow or per closely related feature +- small shared helpers in the same domain test package when setup/cleanup logic truly repeats + +## Explore before writing + +Do not guess command names, flags, or payload fields from memory. Discover them: + +```bash +lark-cli --help +lark-cli --help +lark-cli + -h +lark-cli -h +lark-cli schema .. +``` + +Use this exploration loop repeatedly while writing the testcase: +1. find the right domain and command path +2. decide whether the scenario should use a shortcut or a resource method +3. inspect the exact `--params` and `--data` shape +4. run the draft testcase +5. inspect failures, then go back to help or schema and refine + +Also inspect environmental constraints before finalizing coverage: +- whether the current test environment supports `bot`, `user`, or both +- whether the scenario needs external identities, preexisting groups, documents, chats, or other remote fixtures +- whether the command path is actually executable in CI-like conditions + +## Use the harness directly + +Call `clie2e.RunCmd` with `clie2e.Request`. + +```go +result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{ + "task_guid": taskGUID, + }, +}) +require.NoError(t, err) +result.AssertExitCode(t, 0) +result.AssertStdoutStatus(t, 0) +``` + +Use `Request` like this: +- `Args`: command path and plain flags +- `Params`: JSON for `--params` +- `Data`: JSON for `--data` +- `BinaryPath`, `DefaultAs`, `Format`: only when the testcase must override defaults + +## Default testcase shape + +Use one top-level test per workflow. Break the workflow into substeps with `t.Run`. + +```go +func TestDomain_Scenario(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + var resourceID string + + t.Run("create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{...}) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + resourceID = gjson.Get(result.Stdout, "data.guid").String() + require.NotEmpty(t, resourceID) + + parentT.Cleanup(func() { + // best-effort delete + }) + }) + + t.Run("get", func(t *testing.T) { + require.NotEmpty(t, resourceID) + }) +} +``` + +Use this shape because: +- `t.Run` makes reports readable +- `parentT.Cleanup` keeps created resources alive for later substeps +- one testcase owns one full resource lifecycle + +## Data self-consistency + +Prefer workflows whose data can be created and cleaned up entirely within the testcase. + +Good: +- create a task, then get/update/comment/delete that same task +- create a tasklist, then add a task created by the testcase + +Be explicit when the data is not self-consistent: +- if a testcase needs a real user open_id, preexisting chat, existing document, or tenant-specific fixture, do not invent one +- call out the missing prerequisite to the user +- if you still want to leave a reference testcase in code, write it with `t.Skip()` and a short reason + +Example: + +```go +func TestTask_AssignWorkflow_UserOnly(t *testing.T) { + t.Skip("requires a real user open_id and user-capable test environment") +} +``` + +Do not silently hardcode made-up IDs, fake URLs, or guessed remote resources just to make the testcase look complete. + +## Environment constraints + +Assume the current local/CI-like environment may support only `bot` identity by default. + +Implications: +- do not assume `--as user` works +- commands or workflows that require user identity may be unsupported in the current environment +- confirm this by checking help, running the command, or using known repo guidance before writing the final testcase set + +When `--as user` is unavailable: +- still implement bot-compatible workflows normally +- for user-only workflows, either stop and tell the user what prerequisite is missing, or leave a skipped testcase with `t.Skip()` + +Typical risky areas: +- `+get-my-tasks` +- commands that require current-user profile or self identity lookup +- workflows that need a real user open_id for assign/follower/member mutations + +## Go testing rules + +- Use `t.Run` for lifecycle steps such as `create`, `update`, `get`, `list`, `delete`. +- Use `t.Cleanup` for teardown and shared cleanup. +- Use `t.Helper()` in local helpers when the same setup or assertion logic really repeats. +- Use table-driven tests only when the same scenario shape repeats across multiple inputs. Do not force table-driven style onto a single live workflow. +- Use `require.NoError` for command execution and prerequisites. +- Use `assert` for returned field values after the command has succeeded. +- Use `gjson.Get(result.Stdout, "...")` for JSON field extraction. + +## Output conventions + +- shortcut-style commands often return `{"ok": true, ...}` and should use `result.AssertStdoutStatus(t, true)` +- service-style commands often return `{"code": 0, "data": ...}` and should use `result.AssertStdoutStatus(t, 0)` + +Then assert the business fields with `gjson`. + +## Common mistakes + +- Do not modify `tests/cli_e2e/core.go` just because one testcase wants a convenience wrapper. +- Do not write a testcase that depends on preexisting remote data. +- Do not put agent, model, or vendor brand names into task summaries, comments, tasklist names, fixture IDs, or other visible remote test data; use neutral prefixes such as `lark-cli-e2e-` or `-e2e-`. +- Do not attach cleanup to the create subtest if later subtests still need the resource. +- Do not place new real coverage under `tests/cli_e2e/demo/`. +- Do not dump all domain behaviors into one file or one testcase. +- Do not hardcode obvious defaults unless the command really needs explicit flags. +- Do not guess `Params` or `Data` fields when schema output can tell you the exact shape. +- Do not fabricate prerequisite data when the scenario needs real external fixtures. +- Do not force a user-only workflow to run in a bot-only environment; use `t.Skip()` with a concrete reason. +- Do not stop after the first draft. Run, inspect, explore again, and improve the testcase. + +## Validation + +- Run `go test ./tests/cli_e2e/... -count=1`. +- Rerun the touched package directly when the testcase is live and slow. +- If behavior is unclear, go back to help and schema before changing the testcase. diff --git a/tests/cli_e2e/core.go b/tests/cli_e2e/core.go new file mode 100644 index 00000000..3b6a54bb --- /dev/null +++ b/tests/cli_e2e/core.go @@ -0,0 +1,257 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package clie2e contains end-to-end tests for lark-cli. +package clie2e + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/tidwall/gjson" +) + +const EnvBinaryPath = "LARK_CLI_BIN" +const projectRootMarkerDir = "tests" +const cliBinaryName = "lark-cli" +const defaultIdentity = "bot" + +var defaultAsInitOnce sync.Once + +// Request describes one lark-cli invocation. +type Request struct { + // Args are required and exclude the lark-cli binary name. + Args []string + // Params is optional and becomes --params '' when non-nil. + Params any + // Data is optional and becomes --data '' when non-nil. + Data any + // BinaryPath is optional. Empty means: LARK_CLI_BIN, project-root ./lark-cli, then PATH. + BinaryPath string + // DefaultAs is optional and becomes --as when non-empty. + DefaultAs string + // Format is optional and becomes --format when non-empty. + Format string +} + +// Result captures process execution output. +type Result struct { + BinaryPath string + Args []string + ExitCode int + Stdout string + Stderr string + RunErr error +} + +// RunCmd executes lark-cli and captures stdout/stderr/exit code. +func RunCmd(ctx context.Context, req Request) (*Result, error) { + binaryPath, err := ResolveBinaryPath(req) + if err != nil { + return nil, err + } + + // Best-effort initialization only. Failing to set default-as should not hide + // the actual command-under-test result, because some environments may still + // run the target CLI flow successfully without this convenience setup. + defaultAsInitOnce.Do(func() { + _ = setDefaultAs(ctx, binaryPath, defaultIdentity) + }) + + args, err := BuildArgs(req) + if err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, binaryPath, args...) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + result := &Result{ + BinaryPath: binaryPath, + Args: args, + ExitCode: exitCode(runErr), + Stdout: stdout.String(), + Stderr: stderr.String(), + RunErr: runErr, + } + + return result, nil +} + +// ResolveBinaryPath finds the CLI binary path using request, env, then PATH. +func ResolveBinaryPath(req Request) (string, error) { + if req.BinaryPath != "" { + return normalizeBinaryPath(req.BinaryPath) + } + if envPath := strings.TrimSpace(os.Getenv(EnvBinaryPath)); envPath != "" { + return normalizeBinaryPath(envPath) + } + if rootDir, err := findProjectRootDir(); err == nil { + projectBinary := filepath.Join(rootDir, cliBinaryName) + if _, statErr := os.Stat(projectBinary); statErr == nil { + return normalizeBinaryPath(projectBinary) + } + } + path, err := exec.LookPath(cliBinaryName) + if err == nil { + return normalizeBinaryPath(path) + } + + return "", fmt.Errorf("resolve lark-cli binary: not found via request.BinaryPath, %s, project-root ./%s, PATH:%s", EnvBinaryPath, cliBinaryName, cliBinaryName) +} + +func normalizeBinaryPath(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", errors.New("binary path is empty") + } + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve absolute binary path %q: %w", path, err) + } + info, err := os.Stat(absPath) + if err != nil { + return "", fmt.Errorf("stat binary path %q: %w", absPath, err) + } + if info.IsDir() { + return "", fmt.Errorf("binary path %q is a directory", absPath) + } + if info.Mode()&0o111 == 0 { + return "", fmt.Errorf("binary path %q is not executable", absPath) + } + return absPath, nil +} + +// BuildArgs converts a request into CLI arguments. +func BuildArgs(req Request) ([]string, error) { + args := append([]string{}, req.Args...) + if len(args) == 0 { + return nil, errors.New("request args are required") + } + + if req.DefaultAs != "" { + args = append(args, "--as", req.DefaultAs) + } + if req.Format != "" { + args = append(args, "--format", req.Format) + } + if req.Params != nil { + paramsBytes, err := json.Marshal(req.Params) + if err != nil { + return nil, fmt.Errorf("marshal lark-cli params: %w", err) + } + args = append(args, "--params", string(paramsBytes)) + } + if req.Data != nil { + dataBytes, err := json.Marshal(req.Data) + if err != nil { + return nil, fmt.Errorf("marshal lark-cli data: %w", err) + } + args = append(args, "--data", string(dataBytes)) + } + return args, nil +} + +func findProjectRootDir() (string, error) { + currentDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + for { + markerPath := filepath.Join(currentDir, projectRootMarkerDir) + fileInfo, statErr := os.Stat(markerPath) + if statErr == nil && fileInfo.IsDir() { + return currentDir, nil + } + parentDir := filepath.Dir(currentDir) + if parentDir == "" || parentDir == currentDir { + break + } + currentDir = parentDir + } + return "", fmt.Errorf("project root not found from cwd using marker %q", projectRootMarkerDir) +} + +func setDefaultAs(ctx context.Context, binaryPath string, identity string) error { + cmd := exec.CommandContext(ctx, binaryPath, "config", "default-as", identity) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("set default-as %q: %w; stderr: %s", identity, err, strings.TrimSpace(stderr.String())) + } + return nil +} + +func exitCode(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} + +// StdoutJSON decodes stdout as JSON. +func (r *Result) StdoutJSON(t *testing.T) any { + t.Helper() + return mustParseJSON(t, "stdout", r.Stdout) +} + +// StderrJSON decodes stderr as JSON. +func (r *Result) StderrJSON(t *testing.T) any { + t.Helper() + return mustParseJSON(t, "stderr", r.Stderr) +} + +func mustParseJSON(t *testing.T, stream string, raw string) any { + t.Helper() + if strings.TrimSpace(raw) == "" { + t.Fatalf("%s is empty", stream) + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + t.Fatalf("parse %s as JSON: %v\n%s:\n%s", stream, err, stream, raw) + } + return value +} + +// AssertExitCode asserts the exit code. +func (r *Result) AssertExitCode(t *testing.T, code int) { + t.Helper() + assert.Equal(t, code, r.ExitCode, "stdout:\n%s\nstderr:\n%s", r.Stdout, r.Stderr) +} + +// AssertStdoutStatus asserts stdout JSON status using either {"ok": ...} or {"code": ...}. +// This intentionally keeps one shared assertion entrypoint for CLI E2E call sites, +// so tests can stay uniform across shortcut-style {"ok": ...} responses and +// service-style {"code": ...} responses without branching on response shape. +func (r *Result) AssertStdoutStatus(t *testing.T, expected any) { + t.Helper() + if okResult := gjson.Get(r.Stdout, "ok"); okResult.Exists() { + assert.Equal(t, expected, okResult.Bool(), "stdout:\n%s", r.Stdout) + return + } + + if codeResult := gjson.Get(r.Stdout, "code"); codeResult.Exists() { + assert.Equal(t, expected, int(codeResult.Int()), "stdout:\n%s", r.Stdout) + return + } + + assert.Fail(t, "stdout status key not found; expected ok or code", "stdout:\n%s", r.Stdout) +} diff --git a/tests/cli_e2e/core_test.go b/tests/cli_e2e/core_test.go new file mode 100644 index 00000000..88cd3498 --- /dev/null +++ b/tests/cli_e2e/core_test.go @@ -0,0 +1,350 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package clie2e + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveBinaryPath(t *testing.T) { + t.Run("request binary path wins", func(t *testing.T) { + tmpDir := t.TempDir() + reqBin := mustWriteExecutable(t, filepath.Join(tmpDir, "req-bin")) + envBin := mustWriteExecutable(t, filepath.Join(tmpDir, "env-bin")) + t.Setenv(EnvBinaryPath, envBin) + + got, err := ResolveBinaryPath(Request{BinaryPath: reqBin}) + require.NoError(t, err) + assert.Equal(t, reqBin, got) + }) + + t.Run("uses env binary path", func(t *testing.T) { + tmpDir := t.TempDir() + envBin := mustWriteExecutable(t, filepath.Join(tmpDir, "env-bin")) + t.Setenv(EnvBinaryPath, envBin) + + got, err := ResolveBinaryPath(Request{}) + require.NoError(t, err) + assert.Equal(t, envBin, got) + }) + + t.Run("uses project root binary", func(t *testing.T) { + tmpDir := t.TempDir() + testsDir := filepath.Join(tmpDir, projectRootMarkerDir) + require.NoError(t, os.MkdirAll(testsDir, 0o755)) + projectBin := mustWriteExecutable(t, filepath.Join(tmpDir, cliBinaryName)) + + oldWD, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(testsDir)) + defer func() { + require.NoError(t, os.Chdir(oldWD)) + }() + + t.Setenv(EnvBinaryPath, "") + got, err := ResolveBinaryPath(Request{}) + require.NoError(t, err) + assertSamePath(t, projectBin, got) + }) + + t.Run("rejects non-executable path", func(t *testing.T) { + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "not-exec") + require.NoError(t, os.WriteFile(file, []byte("plain"), 0o644)) + + _, err := ResolveBinaryPath(Request{BinaryPath: file}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable") + }) +} + +func TestBuildArgs(t *testing.T) { + t.Run("encodes json payloads", func(t *testing.T) { + args, err := BuildArgs(Request{ + Args: []string{"task", "+create"}, + Params: map[string]any{"task_guid": "abc"}, + Data: map[string]any{"summary": "hello"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{ + "task", "+create", + "--params", `{"task_guid":"abc"}`, + "--data", `{"summary":"hello"}`, + }, args) + }) + + t.Run("adds default-as and format when set", func(t *testing.T) { + args, err := BuildArgs(Request{ + Args: []string{"task", "+update"}, + DefaultAs: "user", + Format: "pretty", + }) + require.NoError(t, err) + assert.Equal(t, []string{"task", "+update", "--as", "user", "--format", "pretty"}, args) + }) + + t.Run("requires args", func(t *testing.T) { + _, err := BuildArgs(Request{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "args are required") + }) +} + +func TestRunCmd(t *testing.T) { + t.Run("returns stdout json on success", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + result, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"--stdout-json", `{"ok":true}`}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + outMap, ok := result.StdoutJSON(t).(map[string]any) + require.True(t, ok) + assert.Equal(t, true, outMap["ok"]) + }) + + t.Run("captures stderr and exit code on failure", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + result, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"--stderr-json", `{"ok":false}`, "--exit", "3"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 3) + assert.Error(t, result.RunErr) + + errMap, ok := result.StderrJSON(t).(map[string]any) + require.True(t, ok) + assert.Equal(t, false, errMap["ok"]) + }) + + t.Run("defaults default-as to bot", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + result, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"emit-default-as"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "bot", strings.TrimSpace(result.Stdout)) + assert.Equal(t, "bot\n", fake.ReadState(t)) + assert.Equal(t, 1, fake.ReadSetCount(t)) + }) + + t.Run("initializes default-as only once per binary", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + first, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"emit-default-as"}, + }) + require.NoError(t, err) + first.AssertExitCode(t, 0) + assert.Equal(t, "bot", strings.TrimSpace(first.Stdout)) + + second, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"emit-default-as"}, + }) + require.NoError(t, err) + second.AssertExitCode(t, 0) + assert.Equal(t, "bot", strings.TrimSpace(second.Stdout)) + assert.Equal(t, "bot\n", fake.ReadState(t)) + assert.Equal(t, 1, fake.ReadSetCount(t)) + }) + + t.Run("passes explicit default-as as flag and command-line value wins", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + result, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"emit-arg", "--as"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "user", strings.TrimSpace(result.Stdout)) + assert.Equal(t, "bot\n", fake.ReadState(t)) + assert.Equal(t, 1, fake.ReadSetCount(t)) + }) + + t.Run("asserts stdout code payloads", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + result, err := RunCmd(context.Background(), Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"--stdout-json", `{"code":0,"data":{"id":"x"}}`}, + Format: "json", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + }) + + t.Run("default-as init respects context cancellation", func(t *testing.T) { + resetDefaultAsInitForTest() + fake := newFakeCLI(t, "auto") + t.Setenv("FAKE_DEFAULT_AS_SLEEP", "1") + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + result, err := RunCmd(ctx, Request{ + BinaryPath: fake.BinaryPath, + Args: []string{"emit-default-as"}, + }) + require.NoError(t, err) + assert.Error(t, result.RunErr) + assert.ErrorIs(t, result.RunErr, context.DeadlineExceeded) + assert.Equal(t, 0, fake.ReadSetCount(t)) + }) +} + +type fakeCLI struct { + BinaryPath string + statePath string + countPath string +} + +func newFakeCLI(t *testing.T, initialDefaultAs string) fakeCLI { + t.Helper() + + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "default-as.txt") + countPath := filepath.Join(tmpDir, "set-count.txt") + require.NoError(t, os.WriteFile(statePath, []byte(initialDefaultAs+"\n"), 0o644)) + require.NoError(t, os.WriteFile(countPath, []byte("0\n"), 0o644)) + + script := `#!/bin/sh +state_file="__STATE_FILE__" +count_file="__COUNT_FILE__" + +if [ ! -f "$state_file" ]; then + echo "auto" > "$state_file" +fi + +if [ "$1" = "config" ] && [ "$2" = "default-as" ]; then + if [ "$#" -eq 2 ]; then + value=$(tr -d '\r\n' < "$state_file") + echo "default-as: $value" + exit 0 + fi + if [ "$#" -eq 3 ]; then + if [ -n "$FAKE_DEFAULT_AS_SLEEP" ]; then + sleep "$FAKE_DEFAULT_AS_SLEEP" + fi + count=$(tr -d '\r\n' < "$count_file") + count=$((count + 1)) + echo "$count" > "$count_file" + echo "$3" > "$state_file" + exit 0 + fi +fi + +if [ "$1" = "emit-default-as" ]; then + tr -d '\r\n' < "$state_file" + echo + exit 0 +fi + +if [ "$1" = "emit-arg" ]; then + key="$2" + shift 2 + while [ "$#" -gt 1 ]; do + if [ "$1" = "$key" ]; then + echo "$2" + exit 0 + fi + shift + done + exit 1 +fi + +exit_code=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --stdout-json) + echo "$2" + shift 2 + ;; + --stderr-json) + echo "$2" >&2 + shift 2 + ;; + --exit) + exit_code="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +exit "$exit_code" +` + + script = strings.ReplaceAll(script, "__STATE_FILE__", statePath) + script = strings.ReplaceAll(script, "__COUNT_FILE__", countPath) + binaryPath := filepath.Join(tmpDir, "fake-"+cliBinaryName) + require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755)) + + return fakeCLI{ + BinaryPath: binaryPath, + statePath: statePath, + countPath: countPath, + } +} + +func (f fakeCLI) ReadState(t *testing.T) string { + t.Helper() + stateBytes, err := os.ReadFile(f.statePath) + require.NoError(t, err) + return string(stateBytes) +} + +func (f fakeCLI) ReadSetCount(t *testing.T) int { + t.Helper() + countBytes, err := os.ReadFile(f.countPath) + require.NoError(t, err) + count, err := strconv.Atoi(strings.TrimSpace(string(countBytes))) + require.NoError(t, err) + return count +} + +func assertSamePath(t *testing.T, want string, got string) { + t.Helper() + gotReal, err := filepath.EvalSymlinks(got) + require.NoError(t, err) + wantReal, err := filepath.EvalSymlinks(want) + require.NoError(t, err) + assert.Equal(t, wantReal, gotReal) +} + +func mustWriteExecutable(t *testing.T, path string) string { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + absPath, err := filepath.Abs(path) + require.NoError(t, err) + return absPath +} + +func resetDefaultAsInitForTest() { + defaultAsInitOnce = sync.Once{} +} diff --git a/tests/cli_e2e/demo/task_lifecycle_test.go b/tests/cli_e2e/demo/task_lifecycle_test.go new file mode 100644 index 00000000..ddcac9e9 --- /dev/null +++ b/tests/cli_e2e/demo/task_lifecycle_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package demo + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDemo_TaskLifecycle(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + createdSummary := "lark-cli-e2e-create-" + suffix + updatedSummary := "lark-cli-e2e-update-" + suffix + createdDescription := "created by tests/cli_e2e/demo" + updatedDescription := "updated by tests/cli_e2e/demo" + + var taskGUID string + + t.Run("create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+create"}, + Data: map[string]any{ + "summary": createdSummary, + "description": createdDescription, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + taskGUID = gjson.Get(result.Stdout, "data.guid").String() + require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"task", "tasks", "delete"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + if deleteErr != nil { + parentT.Errorf("delete task %s: %v", taskGUID, deleteErr) + return + } + if deleteResult.ExitCode != 0 { + parentT.Errorf("delete task %s failed: exit=%d stderr=%s", taskGUID, deleteResult.ExitCode, deleteResult.Stderr) + } + }) + }) + + t.Run("update", func(t *testing.T) { + require.NotEmpty(t, taskGUID, "task GUID should be created before update") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+update", "--task-id", taskGUID}, + Data: map[string]any{ + "summary": updatedSummary, + "description": updatedDescription, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + }) + + t.Run("get", func(t *testing.T) { + require.NotEmpty(t, taskGUID, "task GUID should be created before get") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.task.summary").String()) + assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.task.description").String()) + }) +} diff --git a/tests/cli_e2e/task/helpers_test.go b/tests/cli_e2e/task/helpers_test.go new file mode 100644 index 00000000..f01c5f9d --- /dev/null +++ b/tests/cli_e2e/task/helpers_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func createTask(t *testing.T, parentT *testing.T, ctx context.Context, req clie2e.Request) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, req) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + taskGUID := gjson.Get(result.Stdout, "data.guid").String() + require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"task", "tasks", "delete"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + if deleteErr != nil { + parentT.Errorf("delete task %s: %v", taskGUID, deleteErr) + return + } + if deleteResult.ExitCode != 0 { + parentT.Errorf("delete task %s failed: exit=%d stdout=%s stderr=%s", taskGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr) + } + }) + + return taskGUID +} + +func createTasklist(t *testing.T, parentT *testing.T, ctx context.Context, req clie2e.Request) string { + t.Helper() + + result, err := clie2e.RunCmd(ctx, req) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + tasklistGUID := gjson.Get(result.Stdout, "data.guid").String() + require.NotEmpty(t, tasklistGUID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"task", "tasklists", "delete"}, + Params: map[string]any{"tasklist_guid": tasklistGUID}, + }) + if deleteErr != nil { + parentT.Errorf("delete tasklist %s: %v", tasklistGUID, deleteErr) + return + } + if deleteResult.ExitCode != 0 { + parentT.Errorf("delete tasklist %s failed: exit=%d stdout=%s stderr=%s", tasklistGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr) + } + }) + + return tasklistGUID +} diff --git a/tests/cli_e2e/task/task_comment_workflow_test.go b/tests/cli_e2e/task/task_comment_workflow_test.go new file mode 100644 index 00000000..8ebf96e3 --- /dev/null +++ b/tests/cli_e2e/task/task_comment_workflow_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestTask_CommentWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + commentContent := "lark-cli-e2e-comment-" + suffix + taskGUID := createTask(t, parentT, ctx, clie2e.Request{ + Args: []string{"task", "+create"}, + Data: map[string]any{ + "summary": "lark-cli-e2e-comment-task-" + suffix, + "description": "created by tests/cli_e2e/task comment workflow", + }, + }) + + t.Run("comment", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+comment", "--task-id", taskGUID, "--content", commentContent}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.NotEmpty(t, gjson.Get(result.Stdout, "data.id").String(), "stdout:\n%s", result.Stdout) + }) +} diff --git a/tests/cli_e2e/task/task_reminder_workflow_test.go b/tests/cli_e2e/task/task_reminder_workflow_test.go new file mode 100644 index 00000000..95e4d9be --- /dev/null +++ b/tests/cli_e2e/task/task_reminder_workflow_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestTask_ReminderWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + taskGUID := createTask(t, parentT, ctx, clie2e.Request{ + Args: []string{"task", "+create"}, + Data: map[string]any{ + "summary": "lark-cli-e2e-reminder-" + suffix, + "description": "created by tests/cli_e2e/task reminder workflow", + "due": map[string]any{ + "timestamp": time.Now().Add(48 * time.Hour).UnixMilli(), + "is_all_day": false, + }, + }, + }) + + t.Run("set reminder", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+reminder", "--task-id", taskGUID, "--set", "30m"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String()) + }) + + t.Run("get task with reminder", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.Equal(t, int64(30), gjson.Get(result.Stdout, "data.task.reminders.0.relative_fire_minute").Int()) + assert.NotEmpty(t, gjson.Get(result.Stdout, "data.task.reminders.0.id").String(), "stdout:\n%s", result.Stdout) + }) + + t.Run("remove reminder", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String()) + }) + + t.Run("get task without reminder", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.False(t, gjson.Get(result.Stdout, "data.task.reminders.0").Exists(), "stdout:\n%s", result.Stdout) + }) +} diff --git a/tests/cli_e2e/task/task_status_workflow_test.go b/tests/cli_e2e/task/task_status_workflow_test.go new file mode 100644 index 00000000..7844b2aa --- /dev/null +++ b/tests/cli_e2e/task/task_status_workflow_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestTask_StatusWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + taskGUID := createTask(t, parentT, ctx, clie2e.Request{ + Args: []string{"task", "+create"}, + Data: map[string]any{ + "summary": "lark-cli-e2e-summary-" + suffix, + "description": "created by tests/cli_e2e/task status workflow", + }, + }) + + t.Run("complete", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+complete", "--task-id", taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String()) + }) + + t.Run("get completed task", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.Equal(t, "done", gjson.Get(result.Stdout, "data.task.status").String()) + assert.NotZero(t, gjson.Get(result.Stdout, "data.task.completed_at").Int(), "stdout:\n%s", result.Stdout) + }) + + t.Run("reopen", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+reopen", "--task-id", taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String()) + }) + + t.Run("get reopened task", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.Equal(t, "todo", gjson.Get(result.Stdout, "data.task.status").String()) + assert.Equal(t, "0", gjson.Get(result.Stdout, "data.task.completed_at").String()) + }) +} diff --git a/tests/cli_e2e/task/tasklist_add_task_workflow_test.go b/tests/cli_e2e/task/tasklist_add_task_workflow_test.go new file mode 100644 index 00000000..8fadf02f --- /dev/null +++ b/tests/cli_e2e/task/tasklist_add_task_workflow_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestTask_TasklistAddTaskWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + tasklistName := "lark-cli-e2e-tasklist-add-" + suffix + taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix + + tasklistGUID := createTasklist(t, parentT, ctx, clie2e.Request{ + Args: []string{"task", "+tasklist-create", "--name", tasklistName}, + }) + taskGUID := createTask(t, parentT, ctx, clie2e.Request{ + Args: []string{"task", "+create"}, + Data: map[string]any{ + "summary": taskSummary, + "description": "created by tests/cli_e2e/task tasklist add workflow", + }, + }) + + t.Run("add task to tasklist", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+tasklist-task-add", "--tasklist-id", tasklistGUID, "--task-id", taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.tasklist_guid").String()) + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.successful_tasks.0.guid").String()) + assert.False(t, gjson.Get(result.Stdout, "data.failed_tasks.0").Exists(), "stdout:\n%s", result.Stdout) + }) + + t.Run("list tasklist tasks", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasklists", "tasks"}, + Params: map[string]any{ + "tasklist_guid": tasklistGUID, + "page_size": 50, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + taskItem := gjson.Get(result.Stdout, `data.items.#(guid=="`+taskGUID+`")`) + assert.True(t, taskItem.Exists(), "stdout:\n%s", result.Stdout) + assert.Equal(t, taskSummary, taskItem.Get("summary").String()) + }) + + t.Run("get task with tasklist link", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.task.tasklists.0.tasklist_guid").String()) + }) +} diff --git a/tests/cli_e2e/task/tasklist_workflow_test.go b/tests/cli_e2e/task/tasklist_workflow_test.go new file mode 100644 index 00000000..d336cc07 --- /dev/null +++ b/tests/cli_e2e/task/tasklist_workflow_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestTask_TasklistWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := time.Now().UTC().Format("20060102-150405") + tasklistName := "lark-cli-e2e-tasklist-" + suffix + taskSummary := "lark-cli-e2e-task-in-tasklist-" + suffix + taskDescription := "created by tests/cli_e2e/task" + + var tasklistGUID string + var taskGUID string + + t.Run("create tasklist with task", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "+tasklist-create", "--name", tasklistName}, + Data: []map[string]any{ + { + "summary": taskSummary, + "description": taskDescription, + }, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + tasklistGUID = gjson.Get(result.Stdout, "data.guid").String() + taskGUID = gjson.Get(result.Stdout, "data.created_tasks.0.guid").String() + require.NotEmpty(t, tasklistGUID, "stdout:\n%s", result.Stdout) + require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"task", "tasks", "delete"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + if deleteErr != nil { + parentT.Errorf("delete task %s: %v", taskGUID, deleteErr) + return + } + if deleteResult.ExitCode != 0 { + parentT.Errorf("delete task %s failed: exit=%d stdout=%s stderr=%s", taskGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr) + } + }) + + parentT.Cleanup(func() { + deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{ + Args: []string{"task", "tasklists", "delete"}, + Params: map[string]any{"tasklist_guid": tasklistGUID}, + }) + if deleteErr != nil { + parentT.Errorf("delete tasklist %s: %v", tasklistGUID, deleteErr) + return + } + if deleteResult.ExitCode != 0 { + parentT.Errorf("delete tasklist %s failed: exit=%d stdout=%s stderr=%s", tasklistGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr) + } + }) + }) + + t.Run("get tasklist", func(t *testing.T) { + require.NotEmpty(t, tasklistGUID, "tasklist GUID should be created before get") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasklists", "get"}, + Params: map[string]any{"tasklist_guid": tasklistGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.tasklist.guid").String()) + assert.Equal(t, tasklistName, gjson.Get(result.Stdout, "data.tasklist.name").String()) + }) + + t.Run("list tasklist tasks", func(t *testing.T) { + require.NotEmpty(t, tasklistGUID, "tasklist GUID should be created before listing tasks") + require.NotEmpty(t, taskGUID, "task GUID should be created before listing tasks") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasklists", "tasks"}, + Params: map[string]any{ + "tasklist_guid": tasklistGUID, + "page_size": 50, + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + taskItem := gjson.Get(result.Stdout, `data.items.#(guid=="`+taskGUID+`")`) + assert.True(t, taskItem.Exists(), "stdout:\n%s", result.Stdout) + assert.Equal(t, taskSummary, taskItem.Get("summary").String()) + }) + + t.Run("get task", func(t *testing.T) { + require.NotEmpty(t, taskGUID, "task GUID should be created before get") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"task", "tasks", "get"}, + Params: map[string]any{"task_guid": taskGUID}, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, 0) + + assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String()) + assert.Equal(t, taskSummary, gjson.Get(result.Stdout, "data.task.summary").String()) + assert.Equal(t, taskDescription, gjson.Get(result.Stdout, "data.task.description").String()) + assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.task.tasklists.0.tasklist_guid").String()) + }) +}