Files
larksuite__cli/shortcuts/common/runner_jq_test.go
sang-neo03 4c1a92caa6 refactor: converge success output through a single Emitter that owns the write (#1899)
* refactor: add output emitter contract and differential harness

Introduce a leaf Emitter in internal/output that composes the existing
output primitives (content-safety scan, envelope, jq, format rendering,
notice) behind a single command-scoped port. The emitter is unwired: no
production caller is migrated, so CLI output stays byte-for-byte unchanged.

A differential test harness drives the real legacy entry points
(RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the
pagination formatter) and asserts byte-identical stdout/stderr plus typed
errors, locking behavior before later slices migrate callers.

* refactor: tighten emitter API and cover pagination with real tests

- split Emitter.Success/PartialFailure and drop EmitOptions.OK so a
  missing ok flag can no longer silently emit ok:false
- give StreamPage its own StreamOptions (format + pretty) instead of
  reusing EmitOptions, making "jq needs aggregation" a compile-time fact
- pin the Emitter jq-error contract (returns error, writes no stderr);
  the caller adapter re-emits the legacy stderr line on migration
- add in-package tests driving the real apiPaginate/servicePaginate over
  a mock transport: multi-page aggregation, empty-result fallback,
  MarkRaw handling, and the business-error raw-response red line

* test: use standard TestFactory harness for pagination tests

Replace the hand-rolled RoundTripper + APIClient construction in the
apiPaginate/servicePaginate tests with cmdutil.TestFactory and its
httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(),
matching the repo's standard HTTP-mocked test convention. Assertions and
coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the
business-error raw-response red line) are unchanged.

* refactor: route success output through the single Emitter port

Migrate the success-output surfaces onto internal/output's Emitter,
byte-for-byte identical (proven by frozen golden diffs and the real
paginate/HandleResponse tests):

- RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now
  build an Emitter and call Success/PartialFailure; emit and outFormat are
  removed. An adapter maps the returned error back to the legacy
  outputErrOnce / jq-error stderr / exit-code behavior.
- WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8
  callers are unchanged.
- apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the
  aggregate and business-error raw-response branches are untouched.
- HandleResponse routes its non-JSON structured-response branch through
  Emitter.Success.

Frozen golden fixtures replace the runtime legacy oracles so the
differential harness cannot go self-referential after migration.

* fix: keep _notice on struct payloads in Emitter's unknown-format fallback

printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path.

* refactor: make the Emitter own write failures and stop mutating inputs

Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers.

- handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation.
- Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten.
- Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures.
- Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed).

* fix: satisfy license-header and forbidigo lint on the emitter changes

- Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top.
- Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports.

* fix: stop legacy CSV wrappers reporting write failures to stderr

Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
2026-07-21 14:32:47 +08:00

380 lines
11 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// newJqTestContext creates a RuntimeContext wired for jq testing.
func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *bytes.Buffer) {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("jq", "", "")
cmd.Flags().String("format", "json", "")
cmd.Flags().String("as", "bot", "")
cmd.ParseFlags(nil)
if jqExpr != "" {
cmd.Flags().Set("jq", jqExpr)
}
if format != "" {
cmd.Flags().Set("format", format)
}
rctx := &RuntimeContext{
ctx: context.Background(),
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Format: format,
JqExpr: jqExpr,
resolvedAs: core.AsBot,
Factory: &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr},
},
}
return rctx, stdout, stderr
}
func TestRuntimeContext_Out_WithJq(t *testing.T) {
rctx, stdout, _ := newJqTestContext(".data.name", "")
rctx.Out(map[string]interface{}{
"name": "Alice",
"age": 30,
}, nil)
out := stdout.String()
if !strings.Contains(out, "Alice") {
t.Errorf("expected jq-filtered 'Alice', got: %s", out)
}
if strings.Contains(out, "age") {
t.Errorf("expected jq to filter out 'age', got: %s", out)
}
}
func TestRuntimeContext_Out_WithJq_Identity(t *testing.T) {
rctx, stdout, _ := newJqTestContext(".ok", "")
rctx.Out(map[string]interface{}{"key": "value"}, nil)
out := strings.TrimSpace(stdout.String())
if out != "true" {
t.Errorf("expected 'true' for .ok, got: %s", out)
}
}
func TestRuntimeContext_OutFormat_WithJq_OverridesFormat(t *testing.T) {
rctx, stdout, _ := newJqTestContext(".data.items", "pretty")
items := []interface{}{"a", "b", "c"}
rctx.OutFormat(map[string]interface{}{
"items": items,
}, nil, func(w io.Writer) {
t.Error("prettyFn should not be called when jq is set")
})
out := stdout.String()
if !strings.Contains(out, "a") || !strings.Contains(out, "b") {
t.Errorf("expected jq-filtered items, got: %s", out)
}
}
func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
rctx, _, stderr := newJqTestContext(".foo | invalid_func_xyz", "")
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
if !strings.Contains(stderr.String(), "error") {
t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String())
}
problem, ok := errs.ProblemOf(rctx.outputErr)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("output error problem = %#v, %v; want validation/invalid_argument", problem, ok)
}
if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitValidation {
t.Fatalf("output error exit code = %d, want %d", got, output.ExitValidation)
}
}
type failingRuntimeOutputWriter struct {
err error
}
func (w failingRuntimeOutputWriter) Write([]byte) (int, error) {
return 0, w.err
}
func TestRuntimeContext_OutRaw_PropagatesWriteError(t *testing.T) {
rctx, _, stderr := newJqTestContext("", "")
sentinel := errors.New("write failed")
rctx.Factory.IOStreams.Out = failingRuntimeOutputWriter{err: sentinel}
rctx.OutRaw(map[string]interface{}{"id": "1"}, nil)
if !errors.Is(rctx.outputErr, sentinel) {
t.Fatalf("OutRaw() output error = %v, want preserved writer cause", rctx.outputErr)
}
problem, ok := errs.ProblemOf(rctx.outputErr)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("OutRaw() problem = %#v, %v; want internal typed error", problem, ok)
}
if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitInternal {
t.Fatalf("OutRaw() exit code = %d, want %d", got, output.ExitInternal)
}
if stderr.Len() != 0 {
t.Fatalf("OutRaw() stderr = %q, want empty", stderr.String())
}
}
func TestRunShortcut_OutRawWriteErrorPropagates(t *testing.T) {
sentinel := errors.New("write failed")
f := newTestFactory()
f.IOStreams.Out = failingRuntimeOutputWriter{err: sentinel}
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
rctx.OutRaw(map[string]interface{}{"id": "1"}, nil)
return nil
},
}
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, f, s, true)
if !errors.Is(err, sentinel) {
t.Fatalf("runShortcut() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("runShortcut() problem = %#v, %v; want internal typed error", problem, ok)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("runShortcut() exit code = %d, want %d", got, output.ExitInternal)
}
}
type testResolvedFileIO struct{}
func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil }
func (testResolvedFileIO) Stat(string) (fileio.FileInfo, error) { return nil, nil }
func (testResolvedFileIO) ResolvePath(path string) (string, error) { return path, nil }
func (testResolvedFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
return nil, nil
}
type capturingFileIOProvider struct {
gotCtx context.Context
fileIO fileio.FileIO
}
func (p *capturingFileIOProvider) Name() string { return "capture" }
func (p *capturingFileIOProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
p.gotCtx = ctx
return p.fileIO
}
func TestRuntimeContext_FileIO_UsesExecutionContext(t *testing.T) {
execCtx := context.WithValue(context.Background(), "key", "value")
resolved := testResolvedFileIO{}
provider := &capturingFileIOProvider{fileIO: resolved}
rctx := &RuntimeContext{
ctx: execCtx,
Factory: &cmdutil.Factory{
FileIOProvider: provider,
},
}
got := rctx.FileIO()
if got != resolved {
t.Fatalf("FileIO() returned %T, want %T", got, resolved)
}
if provider.gotCtx != execCtx {
t.Fatal("ResolveFileIO() did not receive the runtime execution context")
}
}
func newTestShortcutCmd(s *Shortcut, f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{Use: "test-shortcut"}
cmd.SetContext(context.Background())
registerShortcutFlags(cmd, f, s)
return cmd
}
func newTestFactory() *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*core.CliConfig, error) {
return &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
}, nil
},
LarkClient: func() (*lark.Client, error) {
return lark.NewClient("test", "test"), nil
},
IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}},
FileIOProvider: fileio.GetProvider(),
}
}
func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
HasFormat: true,
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
cmd.Flags().Set("jq", ".data")
cmd.Flags().Set("format", "table")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
if err == nil {
t.Fatal("expected error for --jq + --format table conflict")
}
requireValidation(t, err, "mutually exclusive")
}
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
cmd.Flags().Set("jq", "invalid[")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
requireValidation(t, err, "invalid jq expression")
}
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
rctx.Out(map[string]interface{}{"foo": "bar"}, nil)
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
cmd.Flags().Set("jq", ".foo | invalid_func_xyz")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
if err == nil {
t.Fatal("expected error from jq runtime failure to propagate")
}
}
func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
stdout := f.IOStreams.Out.(*bytes.Buffer)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", env)
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", call)
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "test" {
t.Fatalf("runner must inject data.context like the service/api paths, got: %#v", data["context"])
}
}
func TestRunShortcut_DryRunWithJq(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(ctx context.Context, rctx *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(ctx context.Context, rctx *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f := newTestFactory()
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("jq", ".dry_run")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
stdout := f.IOStreams.Out.(*bytes.Buffer)
if got := strings.TrimSpace(stdout.String()); got != "true" {
t.Fatalf("jq output = %q, want true", got)
}
}
func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")
rctx.Out(map[string]interface{}{"key": "value"}, &output.Meta{Count: 1})
out := stdout.String()
if !strings.Contains(out, `"ok"`) || !strings.Contains(out, `"key"`) {
t.Errorf("expected normal JSON envelope, got: %s", out)
}
}