diff --git a/cmd/api/api_paginate_test.go b/cmd/api/api_paginate_test.go new file mode 100644 index 0000000000..5422790a3f --- /dev/null +++ b/cmd/api/api_paginate_test.go @@ -0,0 +1,260 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + previousNotice := output.PendingNotice + output.PendingNotice = nil + t.Cleanup(func() { output.PendingNotice = previousNotice }) + + config := &core.CliConfig{ + AppID: "test-app", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + f, out, errOut, reg := cmdutil.TestFactory(t, config) + ac, err := f.NewAPIClientWithConfig(config) + if err != nil { + t.Fatalf("NewAPIClientWithConfig() error = %v", err) + } + ac.ErrOut = io.Discard + return ac, out, errOut, reg +} + +func apiPaginateRequest() client.RawApiRequest { + return client.RawApiRequest{ + Method: "GET", + URL: "/open-apis/test/v1/items", + As: core.AsBot, + } +} + +func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) { + t.Helper() + wantBytes, err := json.MarshalIndent(want, "", " ") + if err != nil { + t.Fatalf("marshal expected JSON: %v", err) + } + wantBytes = append(wantBytes, '\n') + if !bytes.Equal(got, wantBytes) { + t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes) + } +} + +func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) { + ac, out, errOut, reg := newAPIPaginateTestHarness(t) + calls := 0 + wantTokens := []string{"", "next-1", "next-2"} + for i, wantToken := range wantTokens { + page := i + 1 + hasMore := page < len(wantTokens) + data := map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}}, + "has_more": hasMore, + } + if hasMore { + data["page_token"] = wantTokens[page] + } + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + OnMatch: func(req *http.Request) { + calls++ + if got := req.URL.Query().Get("page_token"); got != wantToken { + t.Errorf("request %d page_token = %q, want %q", page, got, wantToken) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": data, + }, + }) + } + + err := apiPaginate(context.Background(), ac, apiPaginateRequest(), + output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{ + PageLimit: 10, + PageDelay: -1, + }) + + if err != nil { + t.Fatalf("apiPaginate() error = %v, want nil", err) + } + if calls != 3 { + t.Fatalf("pagination requests = %d, want 3", calls) + } + assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{ + OK: true, + Identity: "bot", + Data: map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "1"}, + map[string]interface{}{"id": "2"}, + map[string]interface{}{"id": "3"}, + }, + "has_more": false, + }, + }) + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } +} + +func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) { + ac, out, errOut, reg := newAPIPaginateTestHarness(t) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "name": "Test User", + "user_id": "u123", + }, + }, + }) + + err := apiPaginate(context.Background(), ac, apiPaginateRequest(), + output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}) + + if err != nil { + t.Fatalf("apiPaginate() error = %v, want nil", err) + } + assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{ + OK: true, + Identity: "bot", + Data: map[string]interface{}{ + "name": "Test User", + "user_id": "u123", + }, + }) + wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n" + if got := errOut.String(); got != wantWarning { + t.Fatalf("stderr bytes = %q, want %q", got, wantWarning) + } +} + +func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) { + businessResponse := map[string]interface{}{ + "code": 123456, + "msg": "fixture business error", + "data": map[string]interface{}{"detail": "business failed"}, + } + tests := []struct { + name string + format output.Format + jqExpr string + }{ + {name: "jq", format: output.FormatJSON, jqExpr: ".data.items"}, + {name: "default_json", format: output.FormatJSON}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, out, errOut, reg := newAPIPaginateTestHarness(t) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + Body: businessResponse, + }) + + err := apiPaginate(context.Background(), ac, apiPaginateRequest(), + tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}) + + if err == nil { + t.Fatal("apiPaginate() error = nil, want business error") + } + if !errs.IsRaw(err) { + t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err) + } + assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse) + if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) { + t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes()) + } + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } + }) + } +} + +func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) { + tests := []struct { + name string + format output.Format + jqExpr string + }{ + {name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"}, + {name: "stream_pages", format: output.FormatNDJSON}, + {name: "default_paginate_all", format: output.FormatJSON}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, out, errOut, _ := newAPIPaginateTestHarness(t) + + err := apiPaginate(context.Background(), ac, apiPaginateRequest(), + tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}) + + if err == nil { + t.Fatal("apiPaginate() error = nil, want transport error") + } + if !errs.IsRaw(err) { + t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err) + } + if got := out.String(); got != "" { + t.Fatalf("stdout bytes = %q, want empty", got) + } + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } + }) + } +} + +func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) { + ac, out, errOut, reg := newAPIPaginateTestHarness(t) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + Body: map[string]interface{}{ + "code": 123456, + "msg": "fixture business error", + "data": map[string]interface{}{}, + }, + }) + + err := apiPaginate(context.Background(), ac, apiPaginateRequest(), + output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}) + + if err == nil { + t.Fatal("apiPaginate() error = nil, want business error") + } + if !errs.IsRaw(err) { + t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err) + } + if got := out.String(); got != "" { + t.Fatalf("stdout bytes = %q, want empty", got) + } + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } +} diff --git a/cmd/service/service_paginate_test.go b/cmd/service/service_paginate_test.go new file mode 100644 index 0000000000..f8ce26d255 --- /dev/null +++ b/cmd/service/service_paginate_test.go @@ -0,0 +1,264 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + previousNotice := output.PendingNotice + output.PendingNotice = nil + t.Cleanup(func() { output.PendingNotice = previousNotice }) + + config := &core.CliConfig{ + AppID: "test-app", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + f, out, errOut, reg := cmdutil.TestFactory(t, config) + ac, err := f.NewAPIClientWithConfig(config) + if err != nil { + t.Fatalf("NewAPIClientWithConfig() error = %v", err) + } + ac.ErrOut = io.Discard + return ac, out, errOut, reg +} + +func servicePaginateRequest() client.RawApiRequest { + return client.RawApiRequest{ + Method: "GET", + URL: "/open-apis/test/v1/items", + As: core.AsBot, + } +} + +func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) { + t.Helper() + wantBytes, err := json.MarshalIndent(want, "", " ") + if err != nil { + t.Fatalf("marshal expected JSON: %v", err) + } + wantBytes = append(wantBytes, '\n') + if !bytes.Equal(got, wantBytes) { + t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes) + } +} + +func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) { + ac, out, errOut, reg := newServicePaginateTestHarness(t) + calls := 0 + wantTokens := []string{"", "next-1", "next-2"} + for i, wantToken := range wantTokens { + page := i + 1 + hasMore := page < len(wantTokens) + data := map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}}, + "has_more": hasMore, + } + if hasMore { + data["page_token"] = wantTokens[page] + } + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + OnMatch: func(req *http.Request) { + calls++ + if got := req.URL.Query().Get("page_token"); got != wantToken { + t.Errorf("request %d page_token = %q, want %q", page, got, wantToken) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": data, + }, + }) + } + + err := servicePaginate(context.Background(), ac, servicePaginateRequest(), + output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{ + PageLimit: 10, + PageDelay: -1, + }, ac.CheckResponse) + + if err != nil { + t.Fatalf("servicePaginate() error = %v, want nil", err) + } + if calls != 3 { + t.Fatalf("pagination requests = %d, want 3", calls) + } + assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{ + OK: true, + Identity: "bot", + Data: map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "1"}, + map[string]interface{}{"id": "2"}, + map[string]interface{}{"id": "3"}, + }, + "has_more": false, + }, + }) + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } +} + +func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) { + ac, out, errOut, reg := newServicePaginateTestHarness(t) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "name": "Test User", + "user_id": "u123", + }, + }, + }) + + err := servicePaginate(context.Background(), ac, servicePaginateRequest(), + output.FormatNDJSON, "", out, errOut, "lark-cli test items get", + client.PaginationOptions{PageDelay: -1}, ac.CheckResponse) + + if err != nil { + t.Fatalf("servicePaginate() error = %v, want nil", err) + } + assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{ + OK: true, + Identity: "bot", + Data: map[string]interface{}{ + "name": "Test User", + "user_id": "u123", + }, + }) + wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n" + if got := errOut.String(); got != wantWarning { + t.Fatalf("stderr bytes = %q, want %q", got, wantWarning) + } +} + +func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) { + businessResponse := map[string]interface{}{ + "code": 123456, + "msg": "fixture business error", + "data": map[string]interface{}{"detail": "business failed"}, + } + tests := []struct { + name string + format output.Format + jqExpr string + }{ + {name: "jq", format: output.FormatJSON, jqExpr: ".data.items"}, + {name: "default_json", format: output.FormatJSON}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, out, errOut, reg := newServicePaginateTestHarness(t) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + Body: businessResponse, + }) + + err := servicePaginate(context.Background(), ac, servicePaginateRequest(), + tt.format, tt.jqExpr, out, errOut, "lark-cli test items list", + client.PaginationOptions{PageDelay: -1}, ac.CheckResponse) + + if err == nil { + t.Fatal("servicePaginate() error = nil, want business error") + } + if errs.IsRaw(err) { + t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior") + } + assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse) + if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) { + t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes()) + } + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } + }) + } +} + +func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) { + tests := []struct { + name string + format output.Format + jqExpr string + }{ + {name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"}, + {name: "stream_pages", format: output.FormatNDJSON}, + {name: "default_paginate_all", format: output.FormatJSON}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac, out, errOut, _ := newServicePaginateTestHarness(t) + + err := servicePaginate(context.Background(), ac, servicePaginateRequest(), + tt.format, tt.jqExpr, out, errOut, "lark-cli test items list", + client.PaginationOptions{PageDelay: -1}, ac.CheckResponse) + + if err == nil { + t.Fatal("servicePaginate() error = nil, want transport error") + } + if errs.IsRaw(err) { + t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior") + } + if got := out.String(); got != "" { + t.Fatalf("stdout bytes = %q, want empty", got) + } + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } + }) + } +} + +func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) { + ac, out, errOut, reg := newServicePaginateTestHarness(t) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/items", + Body: map[string]interface{}{ + "code": 123456, + "msg": "fixture business error", + "data": map[string]interface{}{}, + }, + }) + + err := servicePaginate(context.Background(), ac, servicePaginateRequest(), + output.FormatNDJSON, "", out, errOut, "lark-cli test items list", + client.PaginationOptions{PageDelay: -1}, ac.CheckResponse) + + if err == nil { + t.Fatal("servicePaginate() error = nil, want business error") + } + if errs.IsRaw(err) { + t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior") + } + if got := out.String(); got != "" { + t.Fatalf("stdout bytes = %q, want empty", got) + } + if got := errOut.String(); got != "" { + t.Fatalf("stderr bytes = %q, want empty", got) + } +} diff --git a/internal/output/emitter.go b/internal/output/emitter.go new file mode 100644 index 0000000000..ba9be5224d --- /dev/null +++ b/internal/output/emitter.go @@ -0,0 +1,278 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/larksuite/cli/errs" +) + +// NoticeProvider supplies the notice attached to a structured envelope. +// The provider is captured by an Emitter so emission never reads the global +// PendingNotice hook implicitly. +type NoticeProvider func() map[string]interface{} + +// PrettyRenderer writes the human-readable representation of one result. +// colorEnabled is the terminal capability captured when the Emitter is built. +type PrettyRenderer func(w io.Writer, colorEnabled bool) error + +// EmitterConfig contains command-scoped dependencies. A command constructs one +// Emitter and reuses it for its success result or streamed pages. +type EmitterConfig struct { + Out io.Writer + ErrOut io.Writer + CommandPath string + Identity string + ColorEnabled bool + NoticeProvider NoticeProvider +} + +// EmitOptions describes one result's wire representation. +// +// The format contract is explicit: JSON (including the empty default) uses an +// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes +// precedence over Format and filters the JSON Envelope. Raw affects only JSON +// envelope encoding and jq's complex-value encoding. +// +// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit +// (false) and WriteSuccessEnvelope (true) until their callers are migrated. +type EmitOptions struct { + Raw bool + Meta *Meta + Format string + JQ string + DryRun bool + Pretty PrettyRenderer + JQSafetyWarning bool +} + +// StreamOptions describes one streamed page's wire representation. Streaming +// carries page items directly, so it deliberately exposes only the fields that +// affect a single page: the format and, for pretty, its renderer. It has no +// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need +// the aggregated result, which the caller's pagination layer owns before it +// streams pages. +type StreamOptions struct { + Format string + Pretty PrettyRenderer +} + +// Emitter owns all command-scoped output dependencies and pagination state. +// It deliberately has no dependency on client or cmdutil. +type Emitter struct { + out io.Writer + errOut io.Writer + commandPath string + identity string + colorEnabled bool + noticeProvider NoticeProvider + + streamFormat string + streamFormatter *PaginatedFormatter +} + +// NewEmitter constructs a command-scoped output emitter. +func NewEmitter(config EmitterConfig) *Emitter { + return &Emitter{ + out: config.Out, + errOut: config.ErrOut, + commandPath: config.CommandPath, + identity: config.Identity, + colorEnabled: config.ColorEnabled, + noticeProvider: config.NoticeProvider, + } +} + +// Success scans and emits one command result by composing the package's leaf +// primitives. JSON and jq use the standard envelope; pretty, table, csv, and +// ndjson render the business value directly. +func (e *Emitter) Success(data interface{}, opts EmitOptions) error { + if err := e.requireOutput(); err != nil { + return err + } + + if opts.JQ != "" { + return e.emitEnvelope(data, true, opts) + } + + switch opts.Format { + case "", "json": + return e.emitEnvelope(data, true, opts) + case "pretty": + return e.emitPretty(data, opts) + default: + return e.emitFormatted(data, opts.Format) + } +} + +// PartialFailure emits a multi-status result whose envelope honestly reports +// ok:false. It is the typed counterpart to Success for batch operations where +// some items failed but the per-item outcomes are the primary stdout output. +// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the +// caller owns the non-zero exit signal, keeping the Emitter free of exit +// semantics. +func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error { + if err := e.requireOutput(); err != nil { + return err + } + return e.emitEnvelope(data, false, opts) +} + +// StreamPage scans and emits one page while retaining table/csv columns from +// the first page. Streamed output carries page items directly, so it takes a +// StreamOptions (format + optional pretty renderer) rather than the full +// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the +// caller's pagination-layer responsibility, not a per-page concern. Excluding +// jq from the type makes "jq requires aggregated output" a compile-time fact +// instead of a runtime rejection. +func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error { + if err := e.requireOutput(); err != nil { + return err + } + + scanResult := ScanForSafety(e.commandPath, data, e.errOut) + if scanResult.Blocked { + return scanResult.BlockErr + } + if scanResult.Alert != nil && e.errOut != nil { + WriteAlertWarning(e.errOut, scanResult.Alert) + } + + if opts.Format == "pretty" { + if opts.Pretty == nil { + return errs.NewInternalError(errs.SubtypeUnknown, + "pretty output requires a renderer") + } + return opts.Pretty(e.out, e.colorEnabled) + } + + format, known := ParseFormat(opts.Format) + if !known && e.streamFormatter == nil && e.errOut != nil { + fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format) + } + if e.streamFormatter == nil { + e.streamFormat = opts.Format + e.streamFormatter = NewPaginatedFormatter(e.out, format) + } else if opts.Format != e.streamFormat { + return errs.NewInternalError(errs.SubtypeUnknown, + "stream output format changed from %q to %q", e.streamFormat, opts.Format) + } + + e.streamFormatter.FormatPage(data) + return nil +} + +func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error { + scanResult := ScanForSafety(e.commandPath, data, e.errOut) + if scanResult.Blocked { + return scanResult.BlockErr + } + + env := Envelope{ + OK: ok, + Identity: e.identity, + DryRun: opts.DryRun, + Data: data, + Meta: opts.Meta, + Notice: e.notice(), + } + if scanResult.Alert != nil { + env.ContentSafetyAlert = scanResult.Alert + } + + if opts.JQ != "" { + if scanResult.Alert != nil && opts.JQSafetyWarning && e.errOut != nil { + WriteAlertWarning(e.errOut, scanResult.Alert) + } + if opts.Raw { + return JqFilterRaw(e.out, env, opts.JQ) + } + return JqFilter(e.out, env, opts.JQ) + } + + if opts.Raw { + enc := json.NewEncoder(e.out) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(env) + } + PrintJson(e.out, env) + return nil +} + +func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error { + scanResult := ScanForSafety(e.commandPath, data, e.errOut) + if scanResult.Blocked { + return scanResult.BlockErr + } + if scanResult.Alert != nil && e.errOut != nil { + WriteAlertWarning(e.errOut, scanResult.Alert) + } + if opts.Pretty != nil { + return opts.Pretty(e.out, e.colorEnabled) + } + + // RuntimeContext.outFormat falls back through Out/OutRaw when no pretty + // renderer is supplied. Keep that second scan visible in the leaf contract + // until production callers are migrated and the legacy behavior is removed. + return e.emitEnvelope(data, true, opts) +} + +func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error { + scanResult := ScanForSafety(e.commandPath, data, e.errOut) + if scanResult.Blocked { + return scanResult.BlockErr + } + if scanResult.Alert != nil && e.errOut != nil { + WriteAlertWarning(e.errOut, scanResult.Alert) + } + + format, known := ParseFormat(rawFormat) + if !known && e.errOut != nil { + fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat) + } + if format == FormatJSON { + e.printLegacyDataJSON(data) + return nil + } + FormatValue(e.out, data, format) + return nil +} + +type emitterDataMap map[string]interface{} + +// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice +// data from this Emitter instead of PrintJson's global PendingNotice hook. +func (e *Emitter) printLegacyDataJSON(data interface{}) { + if m, ok := data.(map[string]interface{}); ok { + if _, isEnvelope := m["ok"]; isEnvelope { + if notice := e.notice(); notice != nil { + m["_notice"] = notice + } + } + // The named map retains identical JSON bytes while preventing PrintJson + // from consulting its legacy global notice hook a second time. + PrintJson(e.out, emitterDataMap(m)) + return + } + PrintJson(e.out, data) +} + +func (e *Emitter) notice() map[string]interface{} { + if e.noticeProvider == nil { + return nil + } + return e.noticeProvider() +} + +func (e *Emitter) requireOutput() error { + if e == nil || e.out == nil { + return errs.NewInternalError(errs.SubtypeUnknown, + "success output writer is not configured") + } + return nil +} diff --git a/internal/output/emitter_legacy_diff_test.go b/internal/output/emitter_legacy_diff_test.go new file mode 100644 index 0000000000..e906116efa --- /dev/null +++ b/internal/output/emitter_legacy_diff_test.go @@ -0,0 +1,681 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + extcs "github.com/larksuite/cli/extension/contentsafety" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +type emitterCapture struct { + stdout string + stderr string + err error +} + +type emitterSafetyProvider struct { + alert *extcs.Alert + err error +} + +func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" } + +func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) { + return p.alert, p.err +} + +func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) { + previousNotice := output.PendingNotice + t.Cleanup(func() { + output.PendingNotice = previousNotice + extcs.Register(nil) + }) + + type oracleCase struct { + name string + data func() interface{} + raw bool + ok bool + meta *output.Meta + jq string + format string + useFormat bool + pretty bool + notice map[string]interface{} + safetyMode string + safetyAlert *extcs.Alert + safetyErr error + } + + cases := []oracleCase{ + { + name: "json_object", + data: func() interface{} { + return map[string]interface{}{"id": "1", "enabled": true} + }, + ok: true, + }, + { + name: "raw_json_preserves_html", + data: func() interface{} { + return map[string]interface{}{"html": "
a&b
"} + }, + raw: true, + ok: true, + }, + { + name: "partial_failure_ok_false", + data: func() interface{} { + return map[string]interface{}{"succeeded": 1, "failed": 1} + }, + ok: false, + }, + { + name: "metadata", + data: func() interface{} { + return []interface{}{map[string]interface{}{"id": "1"}} + }, + ok: true, + meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"}, + }, + { + name: "jq_scalar", + data: func() interface{} { + return map[string]interface{}{"name": "Alice", "age": 30} + }, + ok: true, + jq: ".data.name", + }, + { + name: "raw_jq_complex", + data: func() interface{} { + return map[string]interface{}{"document": map[string]interface{}{"html": "a&b
"}} + }, + raw: true, + ok: true, + jq: ".data.document", + }, + { + name: "notice", + data: func() interface{} { + return map[string]interface{}{"id": "1"} + }, + ok: true, + notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}, + }, + { + name: "pretty", + data: func() interface{} { + return map[string]interface{}{"name": "Alice"} + }, + ok: true, + format: "pretty", + useFormat: true, + pretty: true, + }, + { + name: "ndjson", + data: func() interface{} { + return map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"id": "1"}, + map[string]interface{}{"id": "2"}, + }} + }, + ok: true, + format: "ndjson", + useFormat: true, + }, + { + name: "table_with_safety_warning", + data: func() interface{} { + return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}} + }, + ok: true, + format: "table", + useFormat: true, + safetyMode: "warn", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + { + name: "csv", + data: func() interface{} { + return []interface{}{ + map[string]interface{}{"id": "1", "name": "Alice"}, + map[string]interface{}{"id": "2", "name": "Bob"}, + } + }, + ok: true, + format: "csv", + useFormat: true, + }, + { + name: "jq_safety_alert_without_stderr_warning", + data: func() interface{} { + return map[string]interface{}{"id": "1"} + }, + ok: true, + jq: ".data.id", + safetyMode: "warn", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + { + name: "scanner_error_fails_open", + data: func() interface{} { + return map[string]interface{}{"id": "1"} + }, + ok: true, + safetyMode: "warn", + safetyErr: errors.New("scanner unavailable"), + }, + { + name: "scanner_block", + data: func() interface{} { + return map[string]interface{}{"id": "blocked"} + }, + ok: true, + safetyMode: "block", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + { + name: "unknown_format_data_envelope_notice", + data: func() interface{} { + return map[string]interface{}{"ok": true, "value": "fixture"} + }, + ok: true, + format: "yaml", + useFormat: true, + notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mode := tc.safetyMode + if mode == "" { + mode = "off" + } + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode) + extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr}) + t.Cleanup(func() { extcs.Register(nil) }) + + notice := tc.notice + output.PendingNotice = func() map[string]interface{} { return notice } + + legacy := runRuntimeContextOracle(t, tc.data(), runtimeOracleOptions{ + raw: tc.raw, + ok: tc.ok, + meta: tc.meta, + jq: tc.jq, + format: tc.format, + useFormat: tc.useFormat, + pretty: tc.pretty, + }) + current := runEmitterSuccess(tc.data(), output.EmitterConfig{ + CommandPath: "lark-cli fixture +emit", + Identity: "bot", + NoticeProvider: func() map[string]interface{} { return notice }, + }, tc.ok, output.EmitOptions{ + Raw: tc.raw, + Meta: tc.meta, + Format: tc.format, + JQ: tc.jq, + Pretty: emitterPrettyRenderer(tc.pretty), + }) + + assertEmitterBytes(t, legacy, current) + if tc.safetyMode == "block" { + var safetyErr *errs.ContentSafetyError + if !errors.As(current.err, &safetyErr) { + t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err) + } + } + }) + } +} + +type runtimeOracleOptions struct { + raw bool + ok bool + meta *output.Meta + jq string + format string + useFormat bool + pretty bool +} + +func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture { + t.Helper() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + parent := &cobra.Command{Use: "lark-cli"} + cmd := &cobra.Command{Use: "fixture"} + leaf := &cobra.Command{Use: "+emit"} + parent.AddCommand(cmd) + cmd.AddCommand(leaf) + + factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}} + runtime := common.TestNewRuntimeContextForAPI( + context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot, + ) + runtime.Format = opts.format + runtime.JqExpr = opts.jq + + pretty := func(w io.Writer) { + fmt.Fprintln(w, "pretty:fixture") + } + if !opts.pretty { + pretty = nil + } + + var err error + switch { + case opts.useFormat && opts.raw: + runtime.OutFormatRaw(data, opts.meta, pretty) + case opts.useFormat: + runtime.OutFormat(data, opts.meta, pretty) + case !opts.ok: + err = runtime.OutPartialFailure(data, opts.meta) + case opts.raw: + runtime.OutRaw(data, opts.meta) + default: + runtime.Out(data, opts.meta) + } + + return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + config.Out = stdout + config.ErrOut = stderr + emitter := output.NewEmitter(config) + var err error + if ok { + err = emitter.Success(data, opts) + } else { + err = emitter.PartialFailure(data, opts) + } + return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func emitterPrettyRenderer(enabled bool) output.PrettyRenderer { + if !enabled { + return nil + } + return func(w io.Writer, _ bool) error { + _, err := fmt.Fprintln(w, "pretty:fixture") + return err + } +} + +func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) { + previousNotice := output.PendingNotice + t.Cleanup(func() { + output.PendingNotice = previousNotice + extcs.Register(nil) + }) + + type oracleCase struct { + name string + data func() interface{} + dryRun bool + jq string + notice map[string]interface{} + safetyMode string + safetyAlert *extcs.Alert + } + cases := []oracleCase{ + { + name: "json", + data: func() interface{} { return map[string]interface{}{"id": "1"} }, + }, + { + name: "dry_run", + data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} }, + dryRun: true, + }, + { + name: "jq", + data: func() interface{} { return map[string]interface{}{"id": "1"} }, + jq: ".data.id", + }, + { + name: "notice", + data: func() interface{} { return map[string]interface{}{"id": "1"} }, + notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}, + }, + { + name: "jq_safety_warning", + data: func() interface{} { return map[string]interface{}{"id": "1"} }, + jq: ".data.id", + safetyMode: "warn", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + { + name: "scanner_block", + data: func() interface{} { return map[string]interface{}{"id": "blocked"} }, + safetyMode: "block", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mode := tc.safetyMode + if mode == "" { + mode = "off" + } + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode) + extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert}) + t.Cleanup(func() { extcs.Register(nil) }) + notice := tc.notice + output.PendingNotice = func() map[string]interface{} { return notice } + + legacy := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq) + current := runEmitterSuccess(tc.data(), output.EmitterConfig{ + CommandPath: "lark-cli fixture +emit", + Identity: "bot", + NoticeProvider: func() map[string]interface{} { return notice }, + }, true, output.EmitOptions{ + Format: "json", + JQ: tc.jq, + DryRun: tc.dryRun, + JQSafetyWarning: true, + }) + + assertEmitterBytes(t, legacy, current) + assertEquivalentError(t, legacy.err, current.err) + }) + } +} + +func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{ + CommandPath: "lark-cli fixture +emit", + Identity: "bot", + DryRun: dryRun, + JqExpr: jq, + Out: stdout, + ErrOut: stderr, + }) + return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) { + t.Cleanup(func() { extcs.Register(nil) }) + + type oracleCase struct { + name string + format output.Format + safetyMode string + safetyAlert *extcs.Alert + } + cases := []oracleCase{ + {name: "ndjson", format: output.FormatNDJSON}, + {name: "table", format: output.FormatTable}, + {name: "csv", format: output.FormatCSV}, + { + name: "warn", + format: output.FormatNDJSON, + safetyMode: "warn", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + { + name: "block", + format: output.FormatTable, + safetyMode: "block", + safetyAlert: &extcs.Alert{ + Provider: "emitter-oracle", + MatchedRules: []string{"fixture-rule"}, + }, + }, + } + + pages := []interface{}{ + []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}, + []interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mode := tc.safetyMode + if mode == "" { + mode = "off" + } + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode) + extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert}) + t.Cleanup(func() { extcs.Register(nil) }) + + legacy := runPaginationOracle(pages, tc.format) + current := runEmitterStreamPages(pages, tc.format.String()) + + assertEmitterBytes(t, legacy, current) + assertEquivalentError(t, legacy.err, current.err) + }) + } +} + +func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + formatter := output.NewPaginatedFormatter(stdout, format) + var emitErr error + for _, page := range pages { + scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr) + if scanResult.Blocked { + emitErr = scanResult.BlockErr + break + } + if scanResult.Alert != nil { + output.WriteAlertWarning(stderr, scanResult.Alert) + } + formatter.FormatPage(page) + } + return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr} +} + +func runEmitterStreamPages(pages []interface{}, format string) emitterCapture { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + emitter := output.NewEmitter(output.EmitterConfig{ + Out: stdout, + ErrOut: stderr, + CommandPath: "lark-cli fixture +emit", + Identity: "bot", + }) + var emitErr error + for _, page := range pages { + if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil { + break + } + } + return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr} +} + +func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) { + previousNotice := output.PendingNotice + output.PendingNotice = func() map[string]interface{} { + return map[string]interface{}{"source": "global"} + } + t.Cleanup(func() { output.PendingNotice = previousNotice }) + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + colorSeen := false + emitter := output.NewEmitter(output.EmitterConfig{ + Out: stdout, + ErrOut: stderr, + CommandPath: "lark-cli fixture +emit", + Identity: "bot", + ColorEnabled: true, + NoticeProvider: func() map[string]interface{} { + return map[string]interface{}{"source": "captured"} + }, + }) + if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil { + t.Fatalf("Emitter.Success() error = %v", err) + } + if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") { + t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String()) + } + + stdout.Reset() + if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty", + Pretty: func(w io.Writer, colorEnabled bool) error { + colorSeen = colorEnabled + _, err := fmt.Fprintln(w, "pretty") + return err + }, + }); err != nil { + t.Fatalf("Emitter.Success(pretty) error = %v", err) + } + if !colorSeen { + t.Fatal("PrettyRenderer did not receive captured ColorEnabled value") + } + + stdout.Reset() + if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil { + t.Fatalf("Emitter.Success(unknown format) error = %v", err) + } + if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") { + t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String()) + } +} + +type failingEmitterWriter struct { + err error +} + +func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err } + +func TestEmitterPropagatesOutputError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + sentinel := errors.New("write failed") + emitter := output.NewEmitter(output.EmitterConfig{ + Out: failingEmitterWriter{err: sentinel}, + ErrOut: io.Discard, + CommandPath: "lark-cli fixture +emit", + }) + err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{ + Raw: true, Format: "json", + JQ: ".data", + }) + if !errors.Is(err, sentinel) { + t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal { + t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok) + } +} + +func TestEmitterRawJSONPropagatesWriteError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off") + sentinel := errors.New("write failed") + emitter := output.NewEmitter(output.EmitterConfig{ + Out: failingEmitterWriter{err: sentinel}, + ErrOut: io.Discard, + CommandPath: "lark-cli fixture +emit", + }) + // Raw JSON without jq must surface the encoder write error, not discard it. + err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{ + Raw: true, Format: "json", + }) + if !errors.Is(err, sentinel) { + t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err) + } +} + +func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) { + // jq failures surface as a returned error and write nothing to stderr. The + // legacy RuntimeContext.emit instead prints "error: