bf9395e022
CI / results (push) Blocked by required conditions
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / e2e-live (push) Waiting to run
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / deadcode (push) Waiting to run
454 lines
15 KiB
Go
454 lines
15 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package apps
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
)
|
|
|
|
func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
err := runAppsShortcut(t, AppsTraceList, []string{
|
|
"+trace-list", "--app-id", "app_x", "--trace-id", "trace-1",
|
|
"--root-span", "gateway", "--user-id", "ou_1",
|
|
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
|
|
"--page-size", "10", "--dry-run", "--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("dry-run err=%v", err)
|
|
}
|
|
|
|
var env struct {
|
|
API []struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Body map[string]interface{} `json:"body"`
|
|
} `json:"api"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
|
}
|
|
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_traces" {
|
|
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
|
}
|
|
if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["limit"] != float64(10) {
|
|
t.Fatalf("body = %#v", env.API[0].Body)
|
|
}
|
|
filter := env.API[0].Body["filter"].(map[string]interface{})
|
|
traceIDs := filter["trace_ids"].([]interface{})
|
|
if len(traceIDs) != 1 || traceIDs[0] != "trace-1" {
|
|
t.Fatalf("filter.trace_ids = %#v", traceIDs)
|
|
}
|
|
if got := filter["keyword"]; got != "gateway" {
|
|
t.Fatalf("filter.keyword = %v", got)
|
|
}
|
|
userIDs := filter["user_ids"].([]interface{})
|
|
if len(userIDs) != 1 || userIDs[0] != "ou_1" {
|
|
t.Fatalf("filter.user_ids = %#v", userIDs)
|
|
}
|
|
if env.API[0].Body["start_timestamp_ns"] != "1782208800000000000" ||
|
|
env.API[0].Body["end_timestamp_ns"] != "1782208860000000000" {
|
|
t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"])
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceList_RejectsDevEnv(t *testing.T) {
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout)
|
|
requireAppsValidationParam(t, err, "--environment")
|
|
}
|
|
|
|
func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
|
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
|
err := runAppsShortcut(t, AppsTraceGet, []string{
|
|
"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--dry-run", "--as", "user",
|
|
}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("dry-run err=%v", err)
|
|
}
|
|
|
|
var env struct {
|
|
API []struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Body map[string]interface{} `json:"body"`
|
|
} `json:"api"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
|
}
|
|
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/trace" {
|
|
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
|
}
|
|
if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["trace_id"] != "trace-1" {
|
|
t.Fatalf("body = %#v", env.API[0].Body)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTraceSummaries_DeduplicatesSpanList(t *testing.T) {
|
|
got := normalizeTraceSummaries([]map[string]interface{}{
|
|
{"trace_id": "trace-1", "name": "gateway"},
|
|
{"traceId": "trace-1", "name": "handler"},
|
|
})
|
|
if len(got) != 1 {
|
|
t.Fatalf("summaries len = %d, want 1: %#v", len(got), got)
|
|
}
|
|
if got[0]["trace_id"] != "trace-1" || got[0]["span_count"] != 2 {
|
|
t.Fatalf("summary = %#v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTraceSummaries_PrefersRootCandidateOverChildOrder(t *testing.T) {
|
|
got := normalizeTraceSummaries([]map[string]interface{}{
|
|
{
|
|
"trace_id": "trace-1",
|
|
"parent_span_id": "span-root",
|
|
"name": "child",
|
|
"status": "ERROR",
|
|
"start_time_ns": "200",
|
|
"duration_ms": 10,
|
|
},
|
|
{
|
|
"traceID": "trace-1",
|
|
"parentSpanID": "",
|
|
"spanName": "root",
|
|
"status": "OK",
|
|
"startTimeNs": "100",
|
|
"durationMs": 200,
|
|
"userID": "ou_root",
|
|
"parent_span_id": "",
|
|
},
|
|
})
|
|
if len(got) != 1 {
|
|
t.Fatalf("summaries len = %d, want 1: %#v", len(got), got)
|
|
}
|
|
summary := got[0]
|
|
if summary["trace_id"] != "trace-1" || summary["span_count"] != 2 {
|
|
t.Fatalf("summary identity/count = %#v", summary)
|
|
}
|
|
if summary["root_span"] != "root" {
|
|
t.Fatalf("root_span = %#v, want root: %#v", summary["root_span"], summary)
|
|
}
|
|
if summary["status"] != "ERROR" {
|
|
t.Fatalf("status = %#v, want ERROR: %#v", summary["status"], summary)
|
|
}
|
|
if summary["start_time_ns"] != "100" {
|
|
t.Fatalf("start_time_ns = %#v, want earliest 100: %#v", summary["start_time_ns"], summary)
|
|
}
|
|
if summary["duration_ms"] != 200 {
|
|
t.Fatalf("duration_ms = %#v, want max 200: %#v", summary["duration_ms"], summary)
|
|
}
|
|
if summary["user_id"] != "ou_root" {
|
|
t.Fatalf("user_id = %#v, want root candidate user: %#v", summary["user_id"], summary)
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceList_NormalizesTraceItemsPaginationVariants(t *testing.T) {
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/spark/v1/apps/app_x/search_traces",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"traceItems": []interface{}{
|
|
map[string]interface{}{
|
|
"traceID": "trace-1",
|
|
"startTimeNs": "1782209472123456789",
|
|
"rootSpan": "gateway",
|
|
"userID": "ou_1",
|
|
"durationMs": float64(123),
|
|
"spanCount": float64(7),
|
|
},
|
|
},
|
|
"nextPageToken": "tok-next",
|
|
"hasMore": true,
|
|
},
|
|
},
|
|
})
|
|
|
|
if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
|
t.Fatalf("execute err=%v", err)
|
|
}
|
|
|
|
var env struct {
|
|
Data struct {
|
|
Items []map[string]interface{} `json:"items"`
|
|
PageToken string `json:"page_token"`
|
|
HasMore bool `json:"has_more"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
|
}
|
|
if env.Data.PageToken != "tok-next" || !env.Data.HasMore {
|
|
t.Fatalf("pagination = token %q has_more %v", env.Data.PageToken, env.Data.HasMore)
|
|
}
|
|
if len(env.Data.Items) != 1 {
|
|
t.Fatalf("items len = %d", len(env.Data.Items))
|
|
}
|
|
item := env.Data.Items[0]
|
|
if item["trace_id"] != "trace-1" || item["root_span"] != "gateway" || item["user_id"] != "ou_1" {
|
|
t.Fatalf("item aliases = %#v", item)
|
|
}
|
|
if item["span_count"] != float64(7) {
|
|
t.Fatalf("span_count = %#v", item["span_count"])
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceList_AggregatesSpansSourceWithSingleSpanPerTrace(t *testing.T) {
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/spark/v1/apps/app_x/search_traces",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"spans": []interface{}{
|
|
map[string]interface{}{
|
|
"traceID": "trace-1",
|
|
"name": "gateway",
|
|
},
|
|
map[string]interface{}{
|
|
"trace_id": "trace-2",
|
|
"span_name": "worker",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
|
t.Fatalf("execute err=%v", err)
|
|
}
|
|
|
|
var env struct {
|
|
Data struct {
|
|
Items []map[string]interface{} `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(env.Data.Items) != 2 {
|
|
t.Fatalf("items len = %d, want 2: %#v", len(env.Data.Items), env.Data.Items)
|
|
}
|
|
wantRootSpan := map[string]string{
|
|
"trace-1": "gateway",
|
|
"trace-2": "worker",
|
|
}
|
|
for _, item := range env.Data.Items {
|
|
traceID, ok := item["trace_id"].(string)
|
|
if !ok || traceID == "" {
|
|
t.Fatalf("missing canonical trace_id: %#v", item)
|
|
}
|
|
if item["span_count"] != float64(1) {
|
|
t.Fatalf("span_count for %s = %#v, want 1: %#v", traceID, item["span_count"], item)
|
|
}
|
|
if item["root_span"] != wantRootSpan[traceID] {
|
|
t.Fatalf("root_span for %s = %#v, want %q: %#v", traceID, item["root_span"], wantRootSpan[traceID], item)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceList_PrettyUsesTraceSummaryColumns(t *testing.T) {
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/spark/v1/apps/app_x/search_traces",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"traceItems": []interface{}{
|
|
map[string]interface{}{
|
|
"traceID": "trace-1",
|
|
"startTimeNs": "1782232472381701316",
|
|
"rootSpan": "GET /app/app_x/api/note-records",
|
|
"userID": "1846640196867498",
|
|
"durationMs": float64(414),
|
|
"status": "OK",
|
|
"spanCount": float64(4),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if err := runAppsShortcut(t, AppsTraceList, []string{
|
|
"+trace-list", "--app-id", "app_x", "--format", "pretty", "--as", "user",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("execute err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
if !strings.HasPrefix(got, "start-time") {
|
|
t.Fatalf("pretty output should start with start-time column, got:\n%s", got)
|
|
}
|
|
for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "GET /app/app_x/api/note-records", "414ms"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("pretty output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, banned := range []string{"span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} {
|
|
if strings.Contains(got, banned) {
|
|
t.Fatalf("pretty output should not include %q:\n%s", banned, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceGet_PrettySummarizesSpans(t *testing.T) {
|
|
const rawNS = int64(1782232472381701316)
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/spark/v1/apps/app_x/trace",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"is_break": false,
|
|
"spans": []interface{}{
|
|
map[string]interface{}{
|
|
"trace_id": "trace-1",
|
|
"name": "GET /app/app_x",
|
|
"span_id": "root",
|
|
"parent_span_id": "",
|
|
"start_time_unix_nano": "1782232472381701316",
|
|
"end_time_unix_nano": "1782232480645457992",
|
|
"attributes": []interface{}{
|
|
map[string]interface{}{"key": "duration_ms", "value": "8263.76"},
|
|
map[string]interface{}{"key": "user_id", "value": "1826968659245100"},
|
|
},
|
|
},
|
|
map[string]interface{}{
|
|
"trace_id": "trace-1",
|
|
"name": "child",
|
|
"span_id": "child",
|
|
"parent_span_id": "root",
|
|
"start_time_unix_nano": "1782232480448000000",
|
|
"attributes": []interface{}{
|
|
map[string]interface{}{"key": "duration_ms", "value": "184.89"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if err := runAppsShortcut(t, AppsTraceGet, []string{
|
|
"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--format", "pretty", "--as", "user",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("execute err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05.000")
|
|
if !strings.HasPrefix(got, "start-time") {
|
|
t.Fatalf("pretty output should start with start-time columns, got:\n%s", got)
|
|
}
|
|
for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "trace-1", "GET /app/app_x", "1826968659245100", wantTime} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("pretty output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, banned := range []string{"start_time_ns", "1782232472381701316", "span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} {
|
|
if strings.Contains(got, banned) {
|
|
t.Fatalf("pretty output should not include %q:\n%s", banned, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) {
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/spark/v1/apps/app_x/trace",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"traceDetail": map[string]interface{}{
|
|
"traceID": "trace-1",
|
|
"isBreak": true,
|
|
"spans": []interface{}{
|
|
map[string]interface{}{
|
|
"spanID": "span-1",
|
|
"parentSpanID": "root",
|
|
"traceID": "trace-1",
|
|
"startTimeNs": "1782209472123456789",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil {
|
|
t.Fatalf("execute err=%v", err)
|
|
}
|
|
|
|
var env struct {
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
|
}
|
|
if _, wrapped := env.Data["trace"]; wrapped {
|
|
t.Fatalf("trace-get should output the trace object directly: %#v", env.Data)
|
|
}
|
|
if env.Data["trace_id"] != "trace-1" || env.Data["is_break"] != true {
|
|
t.Fatalf("trace aliases = %#v", env.Data)
|
|
}
|
|
spans := env.Data["spans"].([]interface{})
|
|
span := spans[0].(map[string]interface{})
|
|
if span["span_id"] != "span-1" || span["parent_span_id"] != "root" || span["trace_id"] != "trace-1" {
|
|
t.Fatalf("span aliases = %#v", span)
|
|
}
|
|
}
|
|
|
|
func TestAppsTraceGet_NormalizesKVAttributesToObject(t *testing.T) {
|
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/spark/v1/apps/app_x/trace",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"spans": []interface{}{
|
|
map[string]interface{}{
|
|
"trace_id": "trace-1",
|
|
"span_id": "span-1",
|
|
"attributes": []interface{}{
|
|
map[string]interface{}{"key": "app_env", "value": "runtime"},
|
|
map[string]interface{}{"key": "duration_ms", "value": "8263"},
|
|
map[string]interface{}{"key": "module", "value": "gateway"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil {
|
|
t.Fatalf("execute err=%v", err)
|
|
}
|
|
|
|
var env struct {
|
|
Data struct {
|
|
Spans []map[string]interface{} `json:"spans"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
|
}
|
|
attrs, ok := env.Data.Spans[0]["attributes"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("attributes = %#v, want object", env.Data.Spans[0]["attributes"])
|
|
}
|
|
if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" {
|
|
t.Fatalf("attributes = %#v", attrs)
|
|
}
|
|
}
|