From dd8f3793a4619887cc461b27293f75307d4ede2e Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 22 Apr 2026 18:05:31 +0100 Subject: [PATCH 1/8] Add workflow list command --- cmd/root.go | 1 + cmd/workflow/list/list.go | 414 +++++++++++++++++++++ cmd/workflow/list/list_test.go | 649 +++++++++++++++++++++++++++++++++ cmd/workflow/workflow.go | 2 + docs/cre_workflow.md | 1 + docs/cre_workflow_list.md | 42 +++ 6 files changed, 1109 insertions(+) create mode 100644 cmd/workflow/list/list.go create mode 100644 cmd/workflow/list/list_test.go create mode 100644 docs/cre_workflow_list.md diff --git a/cmd/root.go b/cmd/root.go index 05dce7bb..c98bd88e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -478,6 +478,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre templates remove": {}, "cre registry": {}, "cre registry list": {}, + "cre workflow list": {}, "cre": {}, } diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go new file mode 100644 index 00000000..b2933be4 --- /dev/null +++ b/cmd/workflow/list/list.go @@ -0,0 +1,414 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/machinebox/graphql" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +const listWorkflowsQuery = ` +query ListWorkflows($input: WorkflowsInput!) { + workflows(input: $input) { + data { + name + workflowId + ownerAddress + status + workflowSource + } + count + } +} +` + +const workflowListPageSize = 100 + +// GraphQLExecutor runs a GraphQL request (implemented by graphqlclient.Client). +type GraphQLExecutor interface { + Execute(ctx context.Context, req *graphql.Request, resp any) error +} + +type workflowRow struct { + Name string `json:"name"` + WorkflowID string `json:"workflowId"` + OwnerAddress string `json:"ownerAddress"` + Status string `json:"status"` + WorkflowSource string `json:"workflowSource"` +} + +// Handler loads and prints workflows (used by the command and tests). +type Handler struct { + credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext + gql GraphQLExecutor +} + +// NewHandler builds a handler with the real GraphQL client. +func NewHandler(ctx *runtime.Context) *Handler { + return NewHandlerWithClient(ctx, nil) +} + +// NewHandlerWithClient builds a handler with an optional GraphQL client (nil uses graphqlclient.New). +func NewHandlerWithClient(ctx *runtime.Context, gql GraphQLExecutor) *Handler { + if gql == nil { + gql = graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + } + return &Handler{ + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + gql: gql, + } +} + +// Execute lists workflows, optionally filtering by registry ID from context.yaml. +// Deleted workflows are omitted unless includeDeleted is true. +func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDeleted bool) error { + if h.tenantCtx == nil { + return fmt.Errorf("user context not available — run `cre login` and retry") + } + + if h.credentials == nil { + return fmt.Errorf("credentials not available — run `cre login` and retry") + } + + if registryFilter != "" { + if findRegistry(h.tenantCtx.Registries, registryFilter) == nil { + return fmt.Errorf("registry %q not found in context.yaml; available: [%s]", + registryFilter, availableRegistryIDs(h.tenantCtx.Registries)) + } + } + + spinner := ui.NewSpinner() + spinner.Start("Listing workflows...") + rows, err := h.fetchAllWorkflows(ctx) + spinner.Stop() + if err != nil { + return err + } + + if registryFilter != "" { + reg := findRegistry(h.tenantCtx.Registries, registryFilter) + rows = filterRowsByRegistry(rows, reg, h.tenantCtx.Registries) + } + + afterRegistryFilter := len(rows) + if !includeDeleted { + rows = omitDeleted(rows) + } + + h.printWorkflows(rows, afterRegistryFilter, includeDeleted) + return nil +} + +// New returns the cobra command. +func New(runtimeContext *runtime.Context) *cobra.Command { + var registryID string + var includeDeleted bool + + cmd := &cobra.Command{ + Use: "list", + Short: "Lists workflows deployed for your organization", + Long: `Lists workflows across registries using the platform API. Requires authentication and user context (context.yaml). Does not use a workflow folder or --target. Deleted workflows are hidden by default.`, + Example: "cre workflow list\n cre workflow list --registry private\n cre workflow list --include-deleted", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return NewHandler(runtimeContext).Execute(cmd.Context(), registryID, includeDeleted) + }, + } + + cmd.Flags().StringVar(®istryID, "registry", "", "Filter by registry ID from context.yaml (e.g. private, onchain:ethereum-testnet-sepolia)") + cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status") + return cmd +} + +func (h *Handler) fetchAllWorkflows(ctx context.Context) ([]workflowRow, error) { + var total int + var all []workflowRow + + for pageNum := 0; ; pageNum++ { + req := graphql.NewRequest(listWorkflowsQuery) + req.Var("input", map[string]any{ + "page": map[string]any{ + "number": pageNum, + "size": workflowListPageSize, + }, + }) + + var envelope struct { + Workflows struct { + Data []workflowRow `json:"data"` + Count int `json:"count"` + } `json:"workflows"` + } + + if err := h.gql.Execute(ctx, req, &envelope); err != nil { + return nil, fmt.Errorf("list workflows: %w", err) + } + + if pageNum == 0 { + total = envelope.Workflows.Count + } + + batch := envelope.Workflows.Data + all = append(all, batch...) + + if len(all) >= total || len(batch) == 0 { + break + } + } + + return all, nil +} + +// filterRowsByRegistry keeps rows whose workflowSource resolves to the given registry. +// API workflowSource values (e.g. contract:…:0x…, grpc:…) rarely equal context registry IDs +// (e.g. onchain:ethereum-testnet-sepolia, private), so matching mirrors formatRegistryDisplay. +func filterRowsByRegistry(rows []workflowRow, reg *tenantctx.Registry, all []*tenantctx.Registry) []workflowRow { + if reg == nil { + return rows + } + out := make([]workflowRow, 0, len(rows)) + for _, r := range rows { + if rowMatchesRegistry(r.WorkflowSource, reg, all) { + out = append(out, r) + } + } + return out +} + +func rowMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { + if workflowSource == reg.ID { + return true + } + + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + return contractSourceMatchesRegistry(workflowSource, reg) + } + + if strings.HasPrefix(workflowSource, "grpc:") { + if !registryEligibleForGrpcRows(reg) { + return false + } + resolved := resolveGrpcSourceRegistry(workflowSource, all) + return resolved != nil && resolved.ID == reg.ID + } + + return false +} + +// registryTypeOffChain mirrors how context.yaml may store OFF_CHAIN / off_chain / off-chain. +func registryTypeOffChain(reg *tenantctx.Registry) bool { + if reg == nil { + return false + } + t := strings.TrimSpace(strings.ReplaceAll(strings.ToLower(reg.Type), "_", "-")) + return t == "off-chain" || strings.EqualFold(strings.TrimSpace(reg.Type), "OFF_CHAIN") +} + +func hasContractAddress(reg *tenantctx.Registry) bool { + return reg != nil && reg.Address != nil && strings.TrimSpace(*reg.Address) != "" +} + +// registryEligibleForContractRows matches formatRegistryDisplay: on-chain rows need a registry +// entry with an address; many manifests omit "type" entirely. +func registryEligibleForContractRows(reg *tenantctx.Registry) bool { + if reg == nil || !hasContractAddress(reg) { + return false + } + if registryTypeOffChain(reg) { + return false + } + return true +} + +// registryEligibleForGrpcRows is true for off-chain registries and for legacy entries +// with no type and no on-chain address (private / grpc-only). +func registryEligibleForGrpcRows(reg *tenantctx.Registry) bool { + if reg == nil { + return false + } + if registryTypeOffChain(reg) { + return true + } + if hasContractAddress(reg) { + return false + } + return true +} + +func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry) bool { + if !registryEligibleForContractRows(reg) { + return false + } + const contractPrefix = "contract:" + rest := strings.TrimPrefix(workflowSource, contractPrefix) + selector, addr, ok := strings.Cut(rest, ":") + if !ok || addr == "" { + return false + } + if !addressesEqual(addr, *reg.Address) { + return false + } + if reg.ChainSelector != nil && strings.TrimSpace(*reg.ChainSelector) != "" && + strings.TrimSpace(*reg.ChainSelector) != strings.TrimSpace(selector) { + return false + } + return true +} + +// resolveGrpcSourceRegistry maps API grpc workflow sources (e.g. grpc:private-grpc-registry:v1) +// to the tenant registry from context.yaml (same rules as --registry). Returns nil if ambiguous +// or unmatched. +func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) *tenantctx.Registry { + if !strings.HasPrefix(workflowSource, "grpc:") { + return nil + } + eligible := make([]*tenantctx.Registry, 0, len(all)) + for _, r := range all { + if r != nil && registryEligibleForGrpcRows(r) { + eligible = append(eligible, r) + } + } + if len(eligible) == 1 { + return eligible[0] + } + var match *tenantctx.Registry + for _, r := range eligible { + id := strings.ToLower(strings.TrimSpace(r.ID)) + if len(id) < 3 { + continue + } + if strings.Contains(strings.ToLower(workflowSource), id) { + if match != nil { + return nil + } + match = r + } + } + return match +} + +func omitDeleted(rows []workflowRow) []workflowRow { + out := make([]workflowRow, 0, len(rows)) + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Status), "DELETED") { + continue + } + out = append(out, r) + } + return out +} + +func (h *Handler) printWorkflows(rows []workflowRow, afterRegistryFilter int, includeDeleted bool) { + ui.Line() + if len(rows) == 0 { + if afterRegistryFilter > 0 && !includeDeleted { + ui.Warning("No workflows found (excluding deleted). Use --include-deleted to list them.") + } else { + ui.Warning("No workflows found") + } + ui.Line() + return + } + + ui.Bold("Workflows") + ui.Line() + + for i, r := range rows { + regCol := formatRegistryDisplay(r.WorkflowSource, h.tenantCtx.Registries) + ui.Bold(fmt.Sprintf("%d. %s", i+1, r.Name)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", r.WorkflowID)) + ui.Dim(fmt.Sprintf(" Owner: %s", r.OwnerAddress)) + ui.Dim(fmt.Sprintf(" Status: %s", r.Status)) + ui.Dim(fmt.Sprintf(" Registry: %s", regCol)) + ui.Line() + } +} + +func formatRegistryDisplay(workflowSource string, registries []*tenantctx.Registry) string { + byID := registryByWorkflowSource(registries) + if reg, ok := byID[workflowSource]; ok { + if reg.Label != "" { + return reg.Label + } + return reg.ID + } + + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + rest := strings.TrimPrefix(workflowSource, contractPrefix) + selector, addr, ok := strings.Cut(rest, ":") + if ok && addr != "" { + for _, r := range registries { + if r == nil || r.Address == nil { + continue + } + if !addressesEqual(addr, *r.Address) { + continue + } + if r.ChainSelector != nil && strings.TrimSpace(*r.ChainSelector) != "" && + strings.TrimSpace(*r.ChainSelector) != strings.TrimSpace(selector) { + continue + } + if r.Label != "" { + return r.Label + } + return r.ID + } + } + } + + if strings.HasPrefix(workflowSource, "grpc:") { + if reg := resolveGrpcSourceRegistry(workflowSource, registries); reg != nil { + return reg.ID + } + } + + return workflowSource +} + +func addressesEqual(a, b string) bool { + return strings.EqualFold( + strings.TrimPrefix(strings.TrimSpace(a), "0x"), + strings.TrimPrefix(strings.TrimSpace(b), "0x"), + ) +} + +func registryByWorkflowSource(registries []*tenantctx.Registry) map[string]*tenantctx.Registry { + m := make(map[string]*tenantctx.Registry) + for _, r := range registries { + if r != nil { + m[r.ID] = r + } + } + return m +} + +func findRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Registry { + for _, r := range registries { + if r != nil && r.ID == id { + return r + } + } + return nil +} + +func availableRegistryIDs(registries []*tenantctx.Registry) string { + ids := make([]string, 0, len(registries)) + for _, r := range registries { + if r != nil { + ids = append(ids, r.ID) + } + } + return strings.Join(ids, ", ") +} diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go new file mode 100644 index 00000000..ebec311f --- /dev/null +++ b/cmd/workflow/list/list_test.go @@ -0,0 +1,649 @@ +package list_test + +import ( + "context" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "github.com/machinebox/graphql" + "github.com/rs/zerolog" + + workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/runtime" + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +// Must match workflowListPageSize in list.go. +const testWorkflowListPageSize = 100 + +func strPtr(s string) *string { return &s } + +func TestNew_NoTenantContext(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: nil, + } + + cmd := workflowlist.New(rtCtx) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when TenantContext is nil") + } + if !strings.Contains(err.Error(), "user context not available") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_NoCredentials(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: nil, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{{ID: "private"}}}, + } + + cmd := workflowlist.New(rtCtx) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when Credentials is nil") + } + if !strings.Contains(err.Error(), "credentials not available") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_UnknownRegistry(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Label: "Private"}}, + }, + } + + cmd := workflowlist.New(rtCtx) + cmd.SetArgs([]string{"--registry", "nope"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unknown registry") + } + if !strings.Contains(err.Error(), "not found in context.yaml") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_RejectsArgs(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{}, + TenantContext: &tenantctx.EnvironmentContext{}, + } + + cmd := workflowlist.New(rtCtx) + cmd.SetArgs([]string{"extra"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + if err := cmd.Execute(); err == nil { + t.Fatal("expected error when extra args provided") + } +} + +type fakeGQL struct { + call int +} + +func (f *fakeGQL) Execute(ctx context.Context, req *graphql.Request, resp any) error { + f.call++ + var body []byte + var err error + + switch f.call { + case 1: + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": 3, + "data": []map[string]string{ + { + "name": "alpha", + "workflowId": "0xaaa", + "ownerAddress": "0xowner1", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "beta", + "workflowId": "0xbbb", + "ownerAddress": "0xowner2", + "status": "PAUSED", + "workflowSource": "other", + }, + { + "name": "gone-deleted", + "workflowId": "0xccc", + "ownerAddress": "0xowner3", + "status": "DELETED", + "workflowSource": "private", + }, + }, + }, + }) + default: + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": 3, + "data": []any{}, + }, + }) + } + if err != nil { + return err + } + return json.Unmarshal(body, resp) +} + +func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + fake := &fakeGQL{} + h := workflowlist.NewHandlerWithClient(rtCtx, fake) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if strings.Contains(out, "gone-deleted") { + t.Errorf("deleted workflow should be omitted by default; output:\n%s", out) + } + for _, want := range []string{ + "Workflows", + "1. alpha", + "Workflow ID:", + "0xaaa", + "Owner:", + "0xowner1", + "Status:", + "ACTIVE", + "Registry:", + "Private hosted", + "2. beta", + "0xbbb", + "0xowner2", + "PAUSED", + "other", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if fake.call != 1 { + t.Errorf("expected single GQL page, got %d calls", fake.call) + } +} + +func TestExecute_WithMock_IncludeDeleted(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Label: "Private hosted"}}, + }, + } + + fake := &fakeGQL{} + h := workflowlist.NewHandlerWithClient(rtCtx, fake) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "", true) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if !strings.Contains(out, "gone-deleted") || !strings.Contains(out, "DELETED") { + t.Errorf("expected deleted workflow with --include-deleted; output:\n%s", out) + } +} + +func TestExecute_AllDeletedShowsHint(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{}}, + } + + deletedOnly := &fakeGQLDeletedOnly{} + h := workflowlist.NewHandlerWithClient(rtCtx, deletedOnly) + + oldStdout := os.Stdout + sr, sw, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = sw + + oldStderr := os.Stderr + er, ew, err := os.Pipe() + if err != nil { + sw.Close() + os.Stdout = oldStdout + t.Fatal(err) + } + os.Stderr = ew + + err = h.Execute(context.Background(), "", false) + sw.Close() + ew.Close() + os.Stdout = oldStdout + os.Stderr = oldStderr + if err != nil { + t.Fatal(err) + } + + var stderrBuf strings.Builder + _, _ = io.Copy(&stderrBuf, er) + errOut := stderrBuf.String() + + if !strings.Contains(errOut, "excluding deleted") || !strings.Contains(errOut, "--include-deleted") { + t.Errorf("expected hint on stderr when all workflows are deleted; stderr:\n%s", errOut) + } + + _, _ = io.Copy(io.Discard, sr) +} + +type fakeGQLDeletedOnly struct{} + +func (f *fakeGQLDeletedOnly) Execute(ctx context.Context, req *graphql.Request, resp any) error { + body, err := json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": 1, + "data": []map[string]string{ + { + "name": "x", + "workflowId": "0x1", + "ownerAddress": "0x2", + "status": "DELETED", + "workflowSource": "private", + }, + }, + }, + }) + if err != nil { + return err + } + return json.Unmarshal(body, resp) +} + +func TestExecute_WithMock_RegistryFilter(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private", Label: "Private hosted"}}, + }, + } + + fake := &fakeGQL{} + h := workflowlist.NewHandlerWithClient(rtCtx, fake) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "private", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if !strings.Contains(out, "alpha") || strings.Contains(out, "beta") { + t.Errorf("expected only private registry row; output:\n%s", out) + } +} + +// fakeGQLMixedRegistries returns one on-chain (contract:…) and one grpc workflow. +type fakeGQLMixedRegistries struct{} + +func (f *fakeGQLMixedRegistries) Execute(ctx context.Context, req *graphql.Request, resp any) error { + chainSel := "16015286601757825753" + addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + body, err := json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []map[string]string{ + { + "name": "onchain-wf", + "workflowId": "00aa", + "ownerAddress": "0xbb", + "status": "ACTIVE", + "workflowSource": "contract:" + chainSel + ":" + addr, + }, + { + "name": "grpc-wf", + "workflowId": "00cc", + "ownerAddress": "0xdd", + "status": "ACTIVE", + "workflowSource": "grpc:private-grpc-registry:v1", + }, + }, + }, + }) + if err != nil { + return err + } + return json.Unmarshal(body, resp) +} + +func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { + chainSel := "16015286601757825753" + addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:ethereum-testnet-sepolia", + Label: "ethereum-testnet-sepolia (0xaE55...1135)", + // type often omitted in context.yaml; matching uses address + chain selector + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private", Type: "off-chain"}, + }, + }, + } + + h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "onchain:ethereum-testnet-sepolia", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if !strings.Contains(out, "onchain-wf") || strings.Contains(out, "grpc-wf") { + t.Errorf("expected only contract-registry workflow; output:\n%s", out) + } +} + +func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { + chainSel := "16015286601757825753" + addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:ethereum-testnet-sepolia", + Label: "sepolia", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "private", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if !strings.Contains(out, "grpc-wf") || strings.Contains(out, "onchain-wf") { + t.Errorf("expected only grpc/private-registry workflow; output:\n%s", out) + } +} + +func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { + chainSel := "16015286601757825753" + addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:ethereum-testnet-sepolia", + Label: "sepolia", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if strings.Contains(out, "grpc:private-grpc-registry:v1") { + t.Errorf("expected grpc source mapped to registry id, not raw API source; output:\n%s", out) + } + idx := strings.Index(out, "grpc-wf") + if idx < 0 { + t.Fatal("expected grpc-wf in output") + } + end := idx + 400 + if end > len(out) { + end = len(out) + } + if !strings.Contains(out[idx:end], "private") { + t.Errorf("expected registry id private near grpc-wf block; output:\n%s", out) + } +} + +type pagingFake struct { + call int +} + +func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any) error { + f.call++ + var body []byte + var err error + switch f.call { + case 1: + data := make([]map[string]string, testWorkflowListPageSize) + for i := range data { + data[i] = map[string]string{ + "name": "wf", + "workflowId": "0x1", + "ownerAddress": "0xo", + "status": "ACTIVE", + "workflowSource": "private", + } + } + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": testWorkflowListPageSize + 2, + "data": data, + }, + }) + case 2: + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": testWorkflowListPageSize + 2, + "data": []map[string]string{ + { + "name": "last", + "workflowId": "0x2", + "ownerAddress": "0xo", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "last2", + "workflowId": "0x3", + "ownerAddress": "0xo", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + default: + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{"count": testWorkflowListPageSize + 2, "data": []any{}}, + }) + } + if err != nil { + return err + } + return json.Unmarshal(body, resp) +} + +func TestExecute_Pagination(t *testing.T) { + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{{ID: "private"}}, + }, + } + + fake := &pagingFake{} + h := workflowlist.NewHandlerWithClient(rtCtx, fake) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + wantRows := testWorkflowListPageSize + 2 + if got := strings.Count(out, "0xo"); got < wantRows { + t.Errorf("expected at least %d owner cells, got %d in:\n%s", wantRows, got, out) + } + if fake.call != 2 { + t.Errorf("expected 2 GQL calls, got %d", fake.call) + } +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index f03a7bdf..cc39b70c 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/deploy" "github.com/smartcontractkit/cre-cli/cmd/workflow/hash" "github.com/smartcontractkit/cre-cli/cmd/workflow/limits" + workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" "github.com/smartcontractkit/cre-cli/cmd/workflow/pause" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate" "github.com/smartcontractkit/cre-cli/cmd/workflow/test" @@ -33,6 +34,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { workflowCmd.AddCommand(hash.New(runtimeContext)) workflowCmd.AddCommand(simulate.New(runtimeContext)) workflowCmd.AddCommand(limits.New()) + workflowCmd.AddCommand(workflowlist.New(runtimeContext)) return workflowCmd } diff --git a/docs/cre_workflow.md b/docs/cre_workflow.md index 0bdfb9a6..00e56839 100644 --- a/docs/cre_workflow.md +++ b/docs/cre_workflow.md @@ -36,6 +36,7 @@ cre workflow [optional flags] * [cre workflow deploy](cre_workflow_deploy.md) - Deploys a workflow to the Workflow Registry contract * [cre workflow hash](cre_workflow_hash.md) - Computes and displays workflow hashes * [cre workflow limits](cre_workflow_limits.md) - Manage simulation limits +* [cre workflow list](cre_workflow_list.md) - Lists workflows deployed for your organization * [cre workflow pause](cre_workflow_pause.md) - Pauses workflow on the Workflow Registry contract * [cre workflow simulate](cre_workflow_simulate.md) - Simulates a workflow diff --git a/docs/cre_workflow_list.md b/docs/cre_workflow_list.md new file mode 100644 index 00000000..55d2ff9b --- /dev/null +++ b/docs/cre_workflow_list.md @@ -0,0 +1,42 @@ +## cre workflow list + +Lists workflows deployed for your organization + +### Synopsis + +Lists workflows across registries using the platform API. Requires authentication and user context (context.yaml). Does not use a workflow folder or --target. Deleted workflows are hidden by default. + +``` +cre workflow list [optional flags] +``` + +### Examples + +``` +cre workflow list + cre workflow list --registry private + cre workflow list --include-deleted +``` + +### Options + +``` + -h, --help help for list + --include-deleted Include workflows in DELETED status + --registry string Filter by registry ID from context.yaml (e.g. private) +``` + +### Options inherited from parent commands + +``` + -e, --env string Path to .env file which contains sensitive info + -R, --project-root string Path to the project root + -E, --public-env string Path to .env.public file which contains shared, non-sensitive build config + -T, --target string Use target settings from YAML config + -v, --verbose Run command in VERBOSE mode +``` + +### SEE ALSO + +* [cre workflow](cre_workflow.md) - Manages workflows + From 5e9422440e49dde7e62ee5485d7e820a0d639aa3 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 22 Apr 2026 18:14:37 +0100 Subject: [PATCH 2/8] Lint --- cmd/workflow/list/list.go | 6 +++--- cmd/workflow/list/list_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index b2933be4..c33c9049 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -47,9 +47,9 @@ type workflowRow struct { // Handler loads and prints workflows (used by the command and tests). type Handler struct { - credentials *credentials.Credentials - tenantCtx *tenantctx.EnvironmentContext - gql GraphQLExecutor + credentials *credentials.Credentials + tenantCtx *tenantctx.EnvironmentContext + gql GraphQLExecutor } // NewHandler builds a handler with the real GraphQL client. diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index ebec311f..17dfb1d8 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -409,8 +409,8 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { - ID: "onchain:ethereum-testnet-sepolia", - Label: "ethereum-testnet-sepolia (0xaE55...1135)", + ID: "onchain:ethereum-testnet-sepolia", + Label: "ethereum-testnet-sepolia (0xaE55...1135)", // type often omitted in context.yaml; matching uses address + chain selector ChainSelector: strPtr(chainSel), Address: strPtr(addr), From d217a7679633265943d7724f198724bde8f9c8a5 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 22 Apr 2026 19:21:42 +0100 Subject: [PATCH 3/8] Clean up --- cmd/root.go | 4 ++-- cmd/workflow/list/list.go | 12 ++++++------ cmd/workflow/list/list_test.go | 4 ++-- docs/cre_workflow_list.md | 4 ++-- internal/settings/registry_resolution.go | 8 ++++---- internal/settings/registry_resolution_test.go | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index c98bd88e..fcdd5c84 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -223,7 +223,7 @@ func newRootCommand() *cobra.Command { spinner.Update("Loading user context...") } if err := runtimeContext.AttachTenantContext(cmd.Context()); err != nil { - runtimeContext.Logger.Warn().Err(err).Msg("failed to load user context — context.yaml not available") + runtimeContext.Logger.Warn().Err(err).Msg("failed to load user context") } // Check if organization is ungated for commands that require it @@ -470,6 +470,7 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre workflow limits": {}, "cre workflow limits export": {}, "cre workflow build": {}, + "cre workflow list": {}, "cre account": {}, "cre secrets": {}, "cre templates": {}, @@ -478,7 +479,6 @@ func isLoadSettings(cmd *cobra.Command) bool { "cre templates remove": {}, "cre registry": {}, "cre registry list": {}, - "cre workflow list": {}, "cre": {}, } diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index c33c9049..506cc417 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -69,7 +69,7 @@ func NewHandlerWithClient(ctx *runtime.Context, gql GraphQLExecutor) *Handler { } } -// Execute lists workflows, optionally filtering by registry ID from context.yaml. +// Execute lists workflows, optionally filtering by registry ID from user context. // Deleted workflows are omitted unless includeDeleted is true. func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDeleted bool) error { if h.tenantCtx == nil { @@ -82,7 +82,7 @@ func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDel if registryFilter != "" { if findRegistry(h.tenantCtx.Registries, registryFilter) == nil { - return fmt.Errorf("registry %q not found in context.yaml; available: [%s]", + return fmt.Errorf("registry %q not found in user context; available: [%s]", registryFilter, availableRegistryIDs(h.tenantCtx.Registries)) } } @@ -117,7 +117,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists workflows deployed for your organization", - Long: `Lists workflows across registries using the platform API. Requires authentication and user context (context.yaml). Does not use a workflow folder or --target. Deleted workflows are hidden by default.`, + Long: `Lists workflows across registries using the platform API. Requires authentication and user context. Does not use a workflow folder or --target. Deleted workflows are hidden by default.`, Example: "cre workflow list\n cre workflow list --registry private\n cre workflow list --include-deleted", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -125,7 +125,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { }, } - cmd.Flags().StringVar(®istryID, "registry", "", "Filter by registry ID from context.yaml (e.g. private, onchain:ethereum-testnet-sepolia)") + cmd.Flags().StringVar(®istryID, "registry", "", "Filter by registry ID from user context") cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status") return cmd } @@ -206,7 +206,7 @@ func rowMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*t return false } -// registryTypeOffChain mirrors how context.yaml may store OFF_CHAIN / off_chain / off-chain. +// registryTypeOffChain mirrors how user context may store OFF_CHAIN / off_chain / off-chain. func registryTypeOffChain(reg *tenantctx.Registry) bool { if reg == nil { return false @@ -267,7 +267,7 @@ func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registr } // resolveGrpcSourceRegistry maps API grpc workflow sources (e.g. grpc:private-grpc-registry:v1) -// to the tenant registry from context.yaml (same rules as --registry). Returns nil if ambiguous +// to the tenant registry from user context (same rules as --registry). Returns nil if ambiguous // or unmatched. func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) *tenantctx.Registry { if !strings.HasPrefix(workflowSource, "grpc:") { diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index 17dfb1d8..1bad44fc 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -80,7 +80,7 @@ func TestNew_UnknownRegistry(t *testing.T) { if err == nil { t.Fatal("expected error for unknown registry") } - if !strings.Contains(err.Error(), "not found in context.yaml") { + if !strings.Contains(err.Error(), "not found in user context") { t.Errorf("unexpected error: %v", err) } } @@ -411,7 +411,7 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { { ID: "onchain:ethereum-testnet-sepolia", Label: "ethereum-testnet-sepolia (0xaE55...1135)", - // type often omitted in context.yaml; matching uses address + chain selector + // type often omitted in user context; matching uses address + chain selector ChainSelector: strPtr(chainSel), Address: strPtr(addr), }, diff --git a/docs/cre_workflow_list.md b/docs/cre_workflow_list.md index 55d2ff9b..db25c274 100644 --- a/docs/cre_workflow_list.md +++ b/docs/cre_workflow_list.md @@ -4,7 +4,7 @@ Lists workflows deployed for your organization ### Synopsis -Lists workflows across registries using the platform API. Requires authentication and user context (context.yaml). Does not use a workflow folder or --target. Deleted workflows are hidden by default. +Lists workflows across registries using the platform API. Requires authentication and user context. Does not use a workflow folder or --target. Deleted workflows are hidden by default. ``` cre workflow list [optional flags] @@ -23,7 +23,7 @@ cre workflow list ``` -h, --help help for list --include-deleted Include workflows in DELETED status - --registry string Filter by registry ID from context.yaml (e.g. private) + --registry string Filter by registry ID from user context ``` ### Options inherited from parent commands diff --git a/internal/settings/registry_resolution.go b/internal/settings/registry_resolution.go index 93249881..0ceb2dfe 100644 --- a/internal/settings/registry_resolution.go +++ b/internal/settings/registry_resolution.go @@ -88,7 +88,7 @@ func ResolveRegistry( reg := findRegistry(tenantCtx.Registries, deploymentRegistry) if reg == nil { - return nil, fmt.Errorf("registry %q not found in context.yaml; available: [%s]", + return nil, fmt.Errorf("registry %q not found in user context; available: [%s]", deploymentRegistry, availableIDs(tenantCtx.Registries)) } @@ -100,11 +100,11 @@ func ResolveRegistry( } if reg.Address == nil || *reg.Address == "" { - return nil, fmt.Errorf("on-chain registry %q has no address in context.yaml", reg.ID) + return nil, fmt.Errorf("on-chain registry %q has no address in user context", reg.ID) } if reg.ChainSelector == nil { - return nil, fmt.Errorf("on-chain registry %q has no chain_selector in context.yaml", reg.ID) + return nil, fmt.Errorf("on-chain registry %q has no chain_selector in user context", reg.ID) } chainName, err := ChainNameFromSelectorString(*reg.ChainSelector) if err != nil { @@ -120,7 +120,7 @@ func ResolveRegistry( ), nil } -// ParseRegistryType converts a raw type string from context.yaml to a +// ParseRegistryType converts a raw type string from user context to a // RegistryType. Unknown values default to on-chain. func ParseRegistryType(raw string) RegistryType { if strings.EqualFold(raw, string(RegistryTypeOffChain)) || strings.EqualFold(raw, "off_chain") { diff --git a/internal/settings/registry_resolution_test.go b/internal/settings/registry_resolution_test.go index 55511952..853f571c 100644 --- a/internal/settings/registry_resolution_test.go +++ b/internal/settings/registry_resolution_test.go @@ -122,7 +122,7 @@ func TestResolveRegistry_UnknownID(t *testing.T) { if err == nil { t.Fatal("expected error for unknown registry ID") } - if !strings.Contains(err.Error(), "not found in context.yaml") { + if !strings.Contains(err.Error(), "not found in user context") { t.Errorf("unexpected error: %v", err) } if !strings.Contains(err.Error(), "onchain:ethereum-testnet-sepolia") { From 7ade8dbe9bfa8ca8f65e33bfa297adaaad386549 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 22 Apr 2026 19:33:02 +0100 Subject: [PATCH 4/8] clean up --- cmd/workflow/list/list.go | 3 +-- internal/settings/registry_resolution.go | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index 506cc417..915cac69 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -117,7 +117,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists workflows deployed for your organization", - Long: `Lists workflows across registries using the platform API. Requires authentication and user context. Does not use a workflow folder or --target. Deleted workflows are hidden by default.`, + Long: `Lists workflows across registries in your organization. Requires authentication and user context. Deleted workflows are hidden by default.`, Example: "cre workflow list\n cre workflow list --registry private\n cre workflow list --include-deleted", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -171,7 +171,6 @@ func (h *Handler) fetchAllWorkflows(ctx context.Context) ([]workflowRow, error) // filterRowsByRegistry keeps rows whose workflowSource resolves to the given registry. // API workflowSource values (e.g. contract:…:0x…, grpc:…) rarely equal context registry IDs -// (e.g. onchain:ethereum-testnet-sepolia, private), so matching mirrors formatRegistryDisplay. func filterRowsByRegistry(rows []workflowRow, reg *tenantctx.Registry, all []*tenantctx.Registry) []workflowRow { if reg == nil { return rows diff --git a/internal/settings/registry_resolution.go b/internal/settings/registry_resolution.go index 0ceb2dfe..21f60627 100644 --- a/internal/settings/registry_resolution.go +++ b/internal/settings/registry_resolution.go @@ -100,11 +100,11 @@ func ResolveRegistry( } if reg.Address == nil || *reg.Address == "" { - return nil, fmt.Errorf("on-chain registry %q has no address in user context", reg.ID) + return nil, fmt.Errorf("on-chain registry %q has no address in user context", reg.ID) } if reg.ChainSelector == nil { - return nil, fmt.Errorf("on-chain registry %q has no chain_selector in user context", reg.ID) + return nil, fmt.Errorf("on-chain registry %q has no chain_selector in user context", reg.ID) } chainName, err := ChainNameFromSelectorString(*reg.ChainSelector) if err != nil { From 5f8e4f438c0c23c817e915d6987db2f1e78267d4 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Wed, 22 Apr 2026 19:51:47 +0100 Subject: [PATCH 5/8] Map registryID --- cmd/workflow/list/list.go | 101 +++++++++++--------- cmd/workflow/list/list_test.go | 169 ++++++++++++++++++++++++++++++++- docs/cre_workflow_list.md | 2 +- 3 files changed, 224 insertions(+), 48 deletions(-) diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index 915cac69..7c56da6d 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -191,7 +191,7 @@ func rowMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*t const contractPrefix = "contract:" if strings.HasPrefix(workflowSource, contractPrefix) { - return contractSourceMatchesRegistry(workflowSource, reg) + return contractSourceMatchesRegistry(workflowSource, reg, all) } if strings.HasPrefix(workflowSource, "grpc:") { @@ -218,7 +218,7 @@ func hasContractAddress(reg *tenantctx.Registry) bool { return reg != nil && reg.Address != nil && strings.TrimSpace(*reg.Address) != "" } -// registryEligibleForContractRows matches formatRegistryDisplay: on-chain rows need a registry +// registryEligibleForContractRows: on-chain rows need a registry // entry with an address; many manifests omit "type" entirely. func registryEligibleForContractRows(reg *tenantctx.Registry) bool { if reg == nil || !hasContractAddress(reg) { @@ -245,24 +245,44 @@ func registryEligibleForGrpcRows(reg *tenantctx.Registry) bool { return true } -func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry) bool { - if !registryEligibleForContractRows(reg) { - return false - } +func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { + found := findContractRegistry(workflowSource, all) + return found != nil && found.ID == reg.ID +} + +func findContractRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return nil + } rest := strings.TrimPrefix(workflowSource, contractPrefix) selector, addr, ok := strings.Cut(rest, ":") if !ok || addr == "" { - return false + return nil } - if !addressesEqual(addr, *reg.Address) { - return false + for _, r := range registries { + if !registryEligibleForContractRows(r) { + continue + } + if !addressesEqual(addr, *r.Address) { + continue + } + if r.ChainSelector != nil && strings.TrimSpace(*r.ChainSelector) != "" && + strings.TrimSpace(*r.ChainSelector) != strings.TrimSpace(selector) { + continue + } + return r } - if reg.ChainSelector != nil && strings.TrimSpace(*reg.ChainSelector) != "" && - strings.TrimSpace(*reg.ChainSelector) != strings.TrimSpace(selector) { - return false + return nil +} + +func parseContractWorkflowSource(workflowSource string) (selector, addr string, ok bool) { + const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return "", "", false } - return true + rest := strings.TrimPrefix(workflowSource, contractPrefix) + return strings.Cut(rest, ":") } // resolveGrpcSourceRegistry maps API grpc workflow sources (e.g. grpc:private-grpc-registry:v1) @@ -324,55 +344,50 @@ func (h *Handler) printWorkflows(rows []workflowRow, afterRegistryFilter int, in ui.Line() for i, r := range rows { - regCol := formatRegistryDisplay(r.WorkflowSource, h.tenantCtx.Registries) + matchedReg := resolveWorkflowRegistry(r.WorkflowSource, h.tenantCtx.Registries) + regIDCol := formatRegistryIDFromResolved(r.WorkflowSource, matchedReg) ui.Bold(fmt.Sprintf("%d. %s", i+1, r.Name)) ui.Dim(fmt.Sprintf(" Workflow ID: %s", r.WorkflowID)) ui.Dim(fmt.Sprintf(" Owner: %s", r.OwnerAddress)) ui.Dim(fmt.Sprintf(" Status: %s", r.Status)) - ui.Dim(fmt.Sprintf(" Registry: %s", regCol)) + ui.Dim(fmt.Sprintf(" Registry: %s", regIDCol)) + if matchedReg != nil && registryEligibleForContractRows(matchedReg) && matchedReg.Address != nil { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matchedReg.Address))) + } else if _, addr, ok := parseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) + } ui.Line() } } -func formatRegistryDisplay(workflowSource string, registries []*tenantctx.Registry) string { +// resolveWorkflowRegistry finds the tenant registry entry for a workflowSource, if any. +func resolveWorkflowRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { byID := registryByWorkflowSource(registries) if reg, ok := byID[workflowSource]; ok { - if reg.Label != "" { - return reg.Label - } - return reg.ID + return reg } + if cr := findContractRegistry(workflowSource, registries); cr != nil { + return cr + } const contractPrefix = "contract:" if strings.HasPrefix(workflowSource, contractPrefix) { - rest := strings.TrimPrefix(workflowSource, contractPrefix) - selector, addr, ok := strings.Cut(rest, ":") - if ok && addr != "" { - for _, r := range registries { - if r == nil || r.Address == nil { - continue - } - if !addressesEqual(addr, *r.Address) { - continue - } - if r.ChainSelector != nil && strings.TrimSpace(*r.ChainSelector) != "" && - strings.TrimSpace(*r.ChainSelector) != strings.TrimSpace(selector) { - continue - } - if r.Label != "" { - return r.Label - } - return r.ID - } - } + return nil } if strings.HasPrefix(workflowSource, "grpc:") { - if reg := resolveGrpcSourceRegistry(workflowSource, registries); reg != nil { - return reg.ID - } + return resolveGrpcSourceRegistry(workflowSource, registries) } + return nil +} + +// formatRegistryIDFromResolved returns the registry ID for display when the API source maps to +// user context (same as "ID:" in cre registry list); otherwise the raw workflowSource from the API. +func formatRegistryIDFromResolved(workflowSource string, matched *tenantctx.Registry) string { + if matched != nil { + return matched.ID + } return workflowSource } diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index 1bad44fc..fc573c9e 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -204,7 +204,7 @@ func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { "Status:", "ACTIVE", "Registry:", - "Private hosted", + "private", "2. beta", "0xbbb", "0xowner2", @@ -532,8 +532,9 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { _, _ = io.Copy(&buf, r) out := buf.String() + // Resolved grpc maps to context "private"; unresolved grpc would print the raw API source. if strings.Contains(out, "grpc:private-grpc-registry:v1") { - t.Errorf("expected grpc source mapped to registry id, not raw API source; output:\n%s", out) + t.Errorf("expected resolved grpc to show context registry id, not raw API source; output:\n%s", out) } idx := strings.Index(out, "grpc-wf") if idx < 0 { @@ -543,8 +544,168 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { if end > len(out) { end = len(out) } - if !strings.Contains(out[idx:end], "private") { - t.Errorf("expected registry id private near grpc-wf block; output:\n%s", out) + if !strings.Contains(out[idx:end], "Registry: private") { + t.Errorf("expected registry private (as in cre registry list) near grpc-wf block; output:\n%s", out) + } + if strings.Contains(out[idx:end], "Address:") { + t.Errorf("did not expect Address line for off-chain/grpc workflow; output:\n%s", out) + } + + idxOn := strings.Index(out, "onchain-wf") + if idxOn < 0 { + t.Fatal("expected onchain-wf in output") + } + endOn := idxOn + 500 + if endOn > len(out) { + endOn = len(out) + } + onChunk := out[idxOn:endOn] + if !strings.Contains(onChunk, "onchain:ethereum-testnet-sepolia") || + !strings.Contains(onChunk, "Registry:") { + t.Errorf("expected on-chain registry as in cre registry list near onchain-wf block; output:\n%s", out) + } + if !strings.Contains(onChunk, "Address:") || + !strings.Contains(onChunk, "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135") { + t.Errorf("expected full registry Address line for on-chain workflow; output:\n%s", onChunk) + } +} + +// fakeGQLOrphanContractAndGrpc: contract address not in user context + one grpc workflow. +type fakeGQLOrphanContractAndGrpc struct{} + +func (f *fakeGQLOrphanContractAndGrpc) Execute(ctx context.Context, req *graphql.Request, resp any) error { + chainSel := "16015286601757825753" + orphanAddr := "0x1111111111111111111111111111111111111111" + body, err := json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []map[string]string{ + { + "name": "orphan-onchain", + "workflowId": "0x01", + "ownerAddress": "0xbb", + "status": "ACTIVE", + "workflowSource": "contract:" + chainSel + ":" + orphanAddr, + }, + { + "name": "grpc-wf", + "workflowId": "0x02", + "ownerAddress": "0xdd", + "status": "ACTIVE", + "workflowSource": "grpc:private-grpc-registry:v1", + }, + }, + }, + }) + if err != nil { + return err + } + return json.Unmarshal(body, resp) +} + +func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { + chainSel := "16015286601757825753" + addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:ethereum-testnet-sepolia", + Label: "sepolia", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + wantSource := "contract:" + chainSel + ":0x1111111111111111111111111111111111111111" + idx := strings.Index(out, "orphan-onchain") + if idx < 0 { + t.Fatal("expected orphan-onchain in output") + } + end := idx + 500 + if end > len(out) { + end = len(out) + } + chunk := out[idx:end] + if !strings.Contains(chunk, "Registry: "+wantSource) { + t.Errorf("expected unmatched contract to show API workflowSource in Registry line; chunk:\n%s", chunk) + } + if !strings.Contains(chunk, "Address:") || + !strings.Contains(chunk, "0x1111111111111111111111111111111111111111") { + t.Errorf("expected Address from workflow source for orphan contract; chunk:\n%s", chunk) + } +} + +func TestExecute_RegistryFilter_PrivateExcludesUnmatchedContract(t *testing.T) { + chainSel := "16015286601757825753" + addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + logger := zerolog.New(io.Discard) + rtCtx := &runtime.Context{ + Logger: &logger, + Credentials: &credentials.Credentials{}, + EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, + TenantContext: &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:ethereum-testnet-sepolia", + Label: "sepolia", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), + }, + {ID: "private", Label: "Private hosted"}, + }, + }, + } + + h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + err = h.Execute(context.Background(), "private", false) + w.Close() + os.Stdout = oldStdout + if err != nil { + t.Fatal(err) + } + + var buf strings.Builder + _, _ = io.Copy(&buf, r) + out := buf.String() + + if !strings.Contains(out, "grpc-wf") || strings.Contains(out, "orphan-onchain") { + t.Errorf("expected private filter to include only grpc workflows resolved to private, not unmatched contract rows; output:\n%s", out) } } diff --git a/docs/cre_workflow_list.md b/docs/cre_workflow_list.md index db25c274..ba95df4f 100644 --- a/docs/cre_workflow_list.md +++ b/docs/cre_workflow_list.md @@ -4,7 +4,7 @@ Lists workflows deployed for your organization ### Synopsis -Lists workflows across registries using the platform API. Requires authentication and user context. Does not use a workflow folder or --target. Deleted workflows are hidden by default. +Lists workflows across registries in your organization. Requires authentication and user context. Deleted workflows are hidden by default. ``` cre workflow list [optional flags] From 82d1a320b39bd58b24c10043faf8b7c7e8786ea7 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Thu, 23 Apr 2026 10:25:22 +0100 Subject: [PATCH 6/8] Review feedback --- cmd/workflow/list/list.go | 344 +-------------------------- cmd/workflow/list/list_test.go | 176 +++++++------- cmd/workflow/list/print.go | 53 +++++ cmd/workflow/list/registry.go | 215 +++++++++++++++++ internal/workflowlist/client.go | 94 ++++++++ internal/workflowlist/client_test.go | 83 +++++++ 6 files changed, 541 insertions(+), 424 deletions(-) create mode 100644 cmd/workflow/list/print.go create mode 100644 cmd/workflow/list/registry.go create mode 100644 internal/workflowlist/client.go create mode 100644 internal/workflowlist/client_test.go diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index 7c56da6d..8caf3c6e 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -3,9 +3,7 @@ package list import ( "context" "fmt" - "strings" - "github.com/machinebox/graphql" "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" @@ -13,52 +11,23 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowlist" ) -const listWorkflowsQuery = ` -query ListWorkflows($input: WorkflowsInput!) { - workflows(input: $input) { - data { - name - workflowId - ownerAddress - status - workflowSource - } - count - } -} -` - -const workflowListPageSize = 100 - -// GraphQLExecutor runs a GraphQL request (implemented by graphqlclient.Client). -type GraphQLExecutor interface { - Execute(ctx context.Context, req *graphql.Request, resp any) error -} - -type workflowRow struct { - Name string `json:"name"` - WorkflowID string `json:"workflowId"` - OwnerAddress string `json:"ownerAddress"` - Status string `json:"status"` - WorkflowSource string `json:"workflowSource"` -} - -// Handler loads and prints workflows (used by the command and tests). +// Handler loads workflows via the list client and prints them. type Handler struct { credentials *credentials.Credentials tenantCtx *tenantctx.EnvironmentContext - gql GraphQLExecutor + gql workflowlist.Executor } -// NewHandler builds a handler with the real GraphQL client. +// NewHandler builds a handler with the real GraphQL executor. func NewHandler(ctx *runtime.Context) *Handler { return NewHandlerWithClient(ctx, nil) } -// NewHandlerWithClient builds a handler with an optional GraphQL client (nil uses graphqlclient.New). -func NewHandlerWithClient(ctx *runtime.Context, gql GraphQLExecutor) *Handler { +// NewHandlerWithClient builds a handler with an optional GraphQL executor (nil uses graphqlclient.New). +func NewHandlerWithClient(ctx *runtime.Context, gql workflowlist.Executor) *Handler { if gql == nil { gql = graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) } @@ -89,7 +58,7 @@ func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDel spinner := ui.NewSpinner() spinner.Start("Listing workflows...") - rows, err := h.fetchAllWorkflows(ctx) + rows, err := workflowlist.ListAll(ctx, h.gql, workflowlist.DefaultPageSize) spinner.Stop() if err != nil { return err @@ -105,7 +74,7 @@ func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDel rows = omitDeleted(rows) } - h.printWorkflows(rows, afterRegistryFilter, includeDeleted) + printWorkflowTable(rows, h.tenantCtx.Registries, afterRegistryFilter, includeDeleted) return nil } @@ -129,300 +98,3 @@ func New(runtimeContext *runtime.Context) *cobra.Command { cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status") return cmd } - -func (h *Handler) fetchAllWorkflows(ctx context.Context) ([]workflowRow, error) { - var total int - var all []workflowRow - - for pageNum := 0; ; pageNum++ { - req := graphql.NewRequest(listWorkflowsQuery) - req.Var("input", map[string]any{ - "page": map[string]any{ - "number": pageNum, - "size": workflowListPageSize, - }, - }) - - var envelope struct { - Workflows struct { - Data []workflowRow `json:"data"` - Count int `json:"count"` - } `json:"workflows"` - } - - if err := h.gql.Execute(ctx, req, &envelope); err != nil { - return nil, fmt.Errorf("list workflows: %w", err) - } - - if pageNum == 0 { - total = envelope.Workflows.Count - } - - batch := envelope.Workflows.Data - all = append(all, batch...) - - if len(all) >= total || len(batch) == 0 { - break - } - } - - return all, nil -} - -// filterRowsByRegistry keeps rows whose workflowSource resolves to the given registry. -// API workflowSource values (e.g. contract:…:0x…, grpc:…) rarely equal context registry IDs -func filterRowsByRegistry(rows []workflowRow, reg *tenantctx.Registry, all []*tenantctx.Registry) []workflowRow { - if reg == nil { - return rows - } - out := make([]workflowRow, 0, len(rows)) - for _, r := range rows { - if rowMatchesRegistry(r.WorkflowSource, reg, all) { - out = append(out, r) - } - } - return out -} - -func rowMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { - if workflowSource == reg.ID { - return true - } - - const contractPrefix = "contract:" - if strings.HasPrefix(workflowSource, contractPrefix) { - return contractSourceMatchesRegistry(workflowSource, reg, all) - } - - if strings.HasPrefix(workflowSource, "grpc:") { - if !registryEligibleForGrpcRows(reg) { - return false - } - resolved := resolveGrpcSourceRegistry(workflowSource, all) - return resolved != nil && resolved.ID == reg.ID - } - - return false -} - -// registryTypeOffChain mirrors how user context may store OFF_CHAIN / off_chain / off-chain. -func registryTypeOffChain(reg *tenantctx.Registry) bool { - if reg == nil { - return false - } - t := strings.TrimSpace(strings.ReplaceAll(strings.ToLower(reg.Type), "_", "-")) - return t == "off-chain" || strings.EqualFold(strings.TrimSpace(reg.Type), "OFF_CHAIN") -} - -func hasContractAddress(reg *tenantctx.Registry) bool { - return reg != nil && reg.Address != nil && strings.TrimSpace(*reg.Address) != "" -} - -// registryEligibleForContractRows: on-chain rows need a registry -// entry with an address; many manifests omit "type" entirely. -func registryEligibleForContractRows(reg *tenantctx.Registry) bool { - if reg == nil || !hasContractAddress(reg) { - return false - } - if registryTypeOffChain(reg) { - return false - } - return true -} - -// registryEligibleForGrpcRows is true for off-chain registries and for legacy entries -// with no type and no on-chain address (private / grpc-only). -func registryEligibleForGrpcRows(reg *tenantctx.Registry) bool { - if reg == nil { - return false - } - if registryTypeOffChain(reg) { - return true - } - if hasContractAddress(reg) { - return false - } - return true -} - -func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { - found := findContractRegistry(workflowSource, all) - return found != nil && found.ID == reg.ID -} - -func findContractRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { - const contractPrefix = "contract:" - if !strings.HasPrefix(workflowSource, contractPrefix) { - return nil - } - rest := strings.TrimPrefix(workflowSource, contractPrefix) - selector, addr, ok := strings.Cut(rest, ":") - if !ok || addr == "" { - return nil - } - for _, r := range registries { - if !registryEligibleForContractRows(r) { - continue - } - if !addressesEqual(addr, *r.Address) { - continue - } - if r.ChainSelector != nil && strings.TrimSpace(*r.ChainSelector) != "" && - strings.TrimSpace(*r.ChainSelector) != strings.TrimSpace(selector) { - continue - } - return r - } - return nil -} - -func parseContractWorkflowSource(workflowSource string) (selector, addr string, ok bool) { - const contractPrefix = "contract:" - if !strings.HasPrefix(workflowSource, contractPrefix) { - return "", "", false - } - rest := strings.TrimPrefix(workflowSource, contractPrefix) - return strings.Cut(rest, ":") -} - -// resolveGrpcSourceRegistry maps API grpc workflow sources (e.g. grpc:private-grpc-registry:v1) -// to the tenant registry from user context (same rules as --registry). Returns nil if ambiguous -// or unmatched. -func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) *tenantctx.Registry { - if !strings.HasPrefix(workflowSource, "grpc:") { - return nil - } - eligible := make([]*tenantctx.Registry, 0, len(all)) - for _, r := range all { - if r != nil && registryEligibleForGrpcRows(r) { - eligible = append(eligible, r) - } - } - if len(eligible) == 1 { - return eligible[0] - } - var match *tenantctx.Registry - for _, r := range eligible { - id := strings.ToLower(strings.TrimSpace(r.ID)) - if len(id) < 3 { - continue - } - if strings.Contains(strings.ToLower(workflowSource), id) { - if match != nil { - return nil - } - match = r - } - } - return match -} - -func omitDeleted(rows []workflowRow) []workflowRow { - out := make([]workflowRow, 0, len(rows)) - for _, r := range rows { - if strings.EqualFold(strings.TrimSpace(r.Status), "DELETED") { - continue - } - out = append(out, r) - } - return out -} - -func (h *Handler) printWorkflows(rows []workflowRow, afterRegistryFilter int, includeDeleted bool) { - ui.Line() - if len(rows) == 0 { - if afterRegistryFilter > 0 && !includeDeleted { - ui.Warning("No workflows found (excluding deleted). Use --include-deleted to list them.") - } else { - ui.Warning("No workflows found") - } - ui.Line() - return - } - - ui.Bold("Workflows") - ui.Line() - - for i, r := range rows { - matchedReg := resolveWorkflowRegistry(r.WorkflowSource, h.tenantCtx.Registries) - regIDCol := formatRegistryIDFromResolved(r.WorkflowSource, matchedReg) - ui.Bold(fmt.Sprintf("%d. %s", i+1, r.Name)) - ui.Dim(fmt.Sprintf(" Workflow ID: %s", r.WorkflowID)) - ui.Dim(fmt.Sprintf(" Owner: %s", r.OwnerAddress)) - ui.Dim(fmt.Sprintf(" Status: %s", r.Status)) - ui.Dim(fmt.Sprintf(" Registry: %s", regIDCol)) - if matchedReg != nil && registryEligibleForContractRows(matchedReg) && matchedReg.Address != nil { - ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matchedReg.Address))) - } else if _, addr, ok := parseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { - ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) - } - ui.Line() - } -} - -// resolveWorkflowRegistry finds the tenant registry entry for a workflowSource, if any. -func resolveWorkflowRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { - byID := registryByWorkflowSource(registries) - if reg, ok := byID[workflowSource]; ok { - return reg - } - - if cr := findContractRegistry(workflowSource, registries); cr != nil { - return cr - } - const contractPrefix = "contract:" - if strings.HasPrefix(workflowSource, contractPrefix) { - return nil - } - - if strings.HasPrefix(workflowSource, "grpc:") { - return resolveGrpcSourceRegistry(workflowSource, registries) - } - - return nil -} - -// formatRegistryIDFromResolved returns the registry ID for display when the API source maps to -// user context (same as "ID:" in cre registry list); otherwise the raw workflowSource from the API. -func formatRegistryIDFromResolved(workflowSource string, matched *tenantctx.Registry) string { - if matched != nil { - return matched.ID - } - return workflowSource -} - -func addressesEqual(a, b string) bool { - return strings.EqualFold( - strings.TrimPrefix(strings.TrimSpace(a), "0x"), - strings.TrimPrefix(strings.TrimSpace(b), "0x"), - ) -} - -func registryByWorkflowSource(registries []*tenantctx.Registry) map[string]*tenantctx.Registry { - m := make(map[string]*tenantctx.Registry) - for _, r := range registries { - if r != nil { - m[r.ID] = r - } - } - return m -} - -func findRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Registry { - for _, r := range registries { - if r != nil && r.ID == id { - return r - } - } - return nil -} - -func availableRegistryIDs(registries []*tenantctx.Registry) string { - ids := make([]string, 0, len(registries)) - for _, r := range registries { - if r != nil { - ids = append(ids, r.ID) - } - } - return strings.Join(ids, ", ") -} diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index fc573c9e..d121e3a3 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -11,16 +11,14 @@ import ( "github.com/machinebox/graphql" "github.com/rs/zerolog" - workflowlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" + cmdlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/workflowlist" ) -// Must match workflowListPageSize in list.go. -const testWorkflowListPageSize = 100 - func strPtr(s string) *string { return &s } func TestNew_NoTenantContext(t *testing.T) { @@ -32,7 +30,7 @@ func TestNew_NoTenantContext(t *testing.T) { TenantContext: nil, } - cmd := workflowlist.New(rtCtx) + cmd := cmdlist.New(rtCtx) cmd.SetArgs([]string{}) err := cmd.Execute() if err == nil { @@ -52,7 +50,7 @@ func TestNew_NoCredentials(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{{ID: "private"}}}, } - cmd := workflowlist.New(rtCtx) + cmd := cmdlist.New(rtCtx) cmd.SetArgs([]string{}) err := cmd.Execute() if err == nil { @@ -74,7 +72,7 @@ func TestNew_UnknownRegistry(t *testing.T) { }, } - cmd := workflowlist.New(rtCtx) + cmd := cmdlist.New(rtCtx) cmd.SetArgs([]string{"--registry", "nope"}) err := cmd.Execute() if err == nil { @@ -94,7 +92,7 @@ func TestNew_RejectsArgs(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{}, } - cmd := workflowlist.New(rtCtx) + cmd := cmdlist.New(rtCtx) cmd.SetArgs([]string{"extra"}) cmd.SilenceUsage = true cmd.SilenceErrors = true @@ -121,22 +119,22 @@ func (f *fakeGQL) Execute(ctx context.Context, req *graphql.Request, resp any) e "data": []map[string]string{ { "name": "alpha", - "workflowId": "0xaaa", - "ownerAddress": "0xowner1", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", "status": "ACTIVE", "workflowSource": "private", }, { "name": "beta", - "workflowId": "0xbbb", - "ownerAddress": "0xowner2", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", "status": "PAUSED", - "workflowSource": "other", + "workflowSource": "contract:999888777666555444333:0xabababababababababababababababababababab", }, { "name": "gone-deleted", - "workflowId": "0xccc", - "ownerAddress": "0xowner3", + "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", + "ownerAddress": "6060606060606060606060606060606060606060", "status": "DELETED", "workflowSource": "private", }, @@ -171,7 +169,7 @@ func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { } fake := &fakeGQL{} - h := workflowlist.NewHandlerWithClient(rtCtx, fake) + h := cmdlist.NewHandlerWithClient(rtCtx, fake) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -198,18 +196,20 @@ func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { "Workflows", "1. alpha", "Workflow ID:", - "0xaaa", + "1010101010101010101010101010101010101010101010101010101010101010", "Owner:", - "0xowner1", + "2020202020202020202020202020202020202020", "Status:", "ACTIVE", "Registry:", "private", "2. beta", - "0xbbb", - "0xowner2", + "Workflow ID:", + "3030303030303030303030303030303030303030303030303030303030303030", + "Owner:", + "4040404040404040404040404040404040404040", "PAUSED", - "other", + "contract:999888777666555444333:0xabababababababababababababababababababab", } { if !strings.Contains(out, want) { t.Errorf("output missing %q:\n%s", want, out) @@ -232,7 +232,7 @@ func TestExecute_WithMock_IncludeDeleted(t *testing.T) { } fake := &fakeGQL{} - h := workflowlist.NewHandlerWithClient(rtCtx, fake) + h := cmdlist.NewHandlerWithClient(rtCtx, fake) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -267,7 +267,7 @@ func TestExecute_AllDeletedShowsHint(t *testing.T) { } deletedOnly := &fakeGQLDeletedOnly{} - h := workflowlist.NewHandlerWithClient(rtCtx, deletedOnly) + h := cmdlist.NewHandlerWithClient(rtCtx, deletedOnly) oldStdout := os.Stdout sr, sw, err := os.Pipe() @@ -313,9 +313,9 @@ func (f *fakeGQLDeletedOnly) Execute(ctx context.Context, req *graphql.Request, "count": 1, "data": []map[string]string{ { - "name": "x", - "workflowId": "0x1", - "ownerAddress": "0x2", + "name": "gone-deleted-only", + "workflowId": "7070707070707070707070707070707070707070707070707070707070707070", + "ownerAddress": "8080808080808080808080808080808080808080", "status": "DELETED", "workflowSource": "private", }, @@ -340,7 +340,7 @@ func TestExecute_WithMock_RegistryFilter(t *testing.T) { } fake := &fakeGQL{} - h := workflowlist.NewHandlerWithClient(rtCtx, fake) + h := cmdlist.NewHandlerWithClient(rtCtx, fake) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -369,25 +369,25 @@ func TestExecute_WithMock_RegistryFilter(t *testing.T) { type fakeGQLMixedRegistries struct{} func (f *fakeGQLMixedRegistries) Execute(ctx context.Context, req *graphql.Request, resp any) error { - chainSel := "16015286601757825753" - addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" body, err := json.Marshal(map[string]any{ "workflows": map[string]any{ "count": 2, "data": []map[string]string{ { "name": "onchain-wf", - "workflowId": "00aa", - "ownerAddress": "0xbb", + "workflowId": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "ownerAddress": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "status": "ACTIVE", "workflowSource": "contract:" + chainSel + ":" + addr, }, { "name": "grpc-wf", - "workflowId": "00cc", - "ownerAddress": "0xdd", + "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", "status": "ACTIVE", - "workflowSource": "grpc:private-grpc-registry:v1", + "workflowSource": "grpc:mock-private-registry:v1", }, }, }, @@ -399,8 +399,8 @@ func (f *fakeGQLMixedRegistries) Execute(ctx context.Context, req *graphql.Reque } func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { - chainSel := "16015286601757825753" - addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, @@ -409,8 +409,8 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { - ID: "onchain:ethereum-testnet-sepolia", - Label: "ethereum-testnet-sepolia (0xaE55...1135)", + ID: "onchain:mock-testnet", + Label: "mock-testnet (short)", // type often omitted in user context; matching uses address + chain selector ChainSelector: strPtr(chainSel), Address: strPtr(addr), @@ -420,7 +420,7 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { }, } - h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -429,7 +429,7 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { } os.Stdout = w - err = h.Execute(context.Background(), "onchain:ethereum-testnet-sepolia", false) + err = h.Execute(context.Background(), "onchain:mock-testnet", false) w.Close() os.Stdout = oldStdout if err != nil { @@ -446,8 +446,8 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { } func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { - chainSel := "16015286601757825753" - addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, @@ -456,8 +456,8 @@ func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { - ID: "onchain:ethereum-testnet-sepolia", - Label: "sepolia", + ID: "onchain:mock-testnet", + Label: "mock", ChainSelector: strPtr(chainSel), Address: strPtr(addr), }, @@ -466,7 +466,7 @@ func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { }, } - h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -492,8 +492,8 @@ func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { } func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { - chainSel := "16015286601757825753" - addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, @@ -502,8 +502,8 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { - ID: "onchain:ethereum-testnet-sepolia", - Label: "sepolia", + ID: "onchain:mock-testnet", + Label: "mock", ChainSelector: strPtr(chainSel), Address: strPtr(addr), }, @@ -512,7 +512,7 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { }, } - h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -533,7 +533,7 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { out := buf.String() // Resolved grpc maps to context "private"; unresolved grpc would print the raw API source. - if strings.Contains(out, "grpc:private-grpc-registry:v1") { + if strings.Contains(out, "grpc:mock-private-registry:v1") { t.Errorf("expected resolved grpc to show context registry id, not raw API source; output:\n%s", out) } idx := strings.Index(out, "grpc-wf") @@ -560,12 +560,12 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { endOn = len(out) } onChunk := out[idxOn:endOn] - if !strings.Contains(onChunk, "onchain:ethereum-testnet-sepolia") || + if !strings.Contains(onChunk, "onchain:mock-testnet") || !strings.Contains(onChunk, "Registry:") { t.Errorf("expected on-chain registry as in cre registry list near onchain-wf block; output:\n%s", out) } if !strings.Contains(onChunk, "Address:") || - !strings.Contains(onChunk, "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135") { + !strings.Contains(onChunk, "0xcafebabe00000000000000000000000000feed") { t.Errorf("expected full registry Address line for on-chain workflow; output:\n%s", onChunk) } } @@ -574,25 +574,25 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { type fakeGQLOrphanContractAndGrpc struct{} func (f *fakeGQLOrphanContractAndGrpc) Execute(ctx context.Context, req *graphql.Request, resp any) error { - chainSel := "16015286601757825753" - orphanAddr := "0x1111111111111111111111111111111111111111" + chainSel := "12345678901234567890" + orphanAddr := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" body, err := json.Marshal(map[string]any{ "workflows": map[string]any{ "count": 2, "data": []map[string]string{ { "name": "orphan-onchain", - "workflowId": "0x01", - "ownerAddress": "0xbb", + "workflowId": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1", + "ownerAddress": "e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2", "status": "ACTIVE", "workflowSource": "contract:" + chainSel + ":" + orphanAddr, }, { "name": "grpc-wf", - "workflowId": "0x02", - "ownerAddress": "0xdd", + "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", "status": "ACTIVE", - "workflowSource": "grpc:private-grpc-registry:v1", + "workflowSource": "grpc:mock-private-registry:v1", }, }, }, @@ -604,8 +604,8 @@ func (f *fakeGQLOrphanContractAndGrpc) Execute(ctx context.Context, req *graphql } func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { - chainSel := "16015286601757825753" - addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, @@ -614,8 +614,8 @@ func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { - ID: "onchain:ethereum-testnet-sepolia", - Label: "sepolia", + ID: "onchain:mock-testnet", + Label: "mock", ChainSelector: strPtr(chainSel), Address: strPtr(addr), }, @@ -624,7 +624,7 @@ func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { }, } - h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) + h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -644,7 +644,7 @@ func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { _, _ = io.Copy(&buf, r) out := buf.String() - wantSource := "contract:" + chainSel + ":0x1111111111111111111111111111111111111111" + wantSource := "contract:" + chainSel + ":0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" idx := strings.Index(out, "orphan-onchain") if idx < 0 { t.Fatal("expected orphan-onchain in output") @@ -658,14 +658,14 @@ func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { t.Errorf("expected unmatched contract to show API workflowSource in Registry line; chunk:\n%s", chunk) } if !strings.Contains(chunk, "Address:") || - !strings.Contains(chunk, "0x1111111111111111111111111111111111111111") { + !strings.Contains(chunk, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { t.Errorf("expected Address from workflow source for orphan contract; chunk:\n%s", chunk) } } func TestExecute_RegistryFilter_PrivateExcludesUnmatchedContract(t *testing.T) { - chainSel := "16015286601757825753" - addr := "0xaE55eB3EDAc48a1163EE2cbb1205bE1e90Ea1135" + chainSel := "12345678901234567890" + addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, @@ -674,8 +674,8 @@ func TestExecute_RegistryFilter_PrivateExcludesUnmatchedContract(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{ Registries: []*tenantctx.Registry{ { - ID: "onchain:ethereum-testnet-sepolia", - Label: "sepolia", + ID: "onchain:mock-testnet", + Label: "mock", ChainSelector: strPtr(chainSel), Address: strPtr(addr), }, @@ -684,7 +684,7 @@ func TestExecute_RegistryFilter_PrivateExcludesUnmatchedContract(t *testing.T) { }, } - h := workflowlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) + h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -719,38 +719,38 @@ func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any var err error switch f.call { case 1: - data := make([]map[string]string, testWorkflowListPageSize) + data := make([]map[string]string, workflowlist.DefaultPageSize) for i := range data { data[i] = map[string]string{ - "name": "wf", - "workflowId": "0x1", - "ownerAddress": "0xo", + "name": "wf-page-batch", + "workflowId": "9191919191919191919191919191919191919191919191919191919191919191", + "ownerAddress": "9292929292929292929292929292929292929292", "status": "ACTIVE", "workflowSource": "private", } } body, err = json.Marshal(map[string]any{ "workflows": map[string]any{ - "count": testWorkflowListPageSize + 2, + "count": workflowlist.DefaultPageSize + 2, "data": data, }, }) case 2: body, err = json.Marshal(map[string]any{ "workflows": map[string]any{ - "count": testWorkflowListPageSize + 2, + "count": workflowlist.DefaultPageSize + 2, "data": []map[string]string{ { - "name": "last", - "workflowId": "0x2", - "ownerAddress": "0xo", + "name": "wf-page-tail-1", + "workflowId": "9393939393939393939393939393939393939393939393939393939393939393", + "ownerAddress": "9292929292929292929292929292929292929292", "status": "ACTIVE", "workflowSource": "private", }, { - "name": "last2", - "workflowId": "0x3", - "ownerAddress": "0xo", + "name": "wf-page-tail-2", + "workflowId": "9494949494949494949494949494949494949494949494949494949494949494", + "ownerAddress": "9292929292929292929292929292929292929292", "status": "ACTIVE", "workflowSource": "private", }, @@ -759,7 +759,7 @@ func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any }) default: body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{"count": testWorkflowListPageSize + 2, "data": []any{}}, + "workflows": map[string]any{"count": workflowlist.DefaultPageSize + 2, "data": []any{}}, }) } if err != nil { @@ -780,7 +780,7 @@ func TestExecute_Pagination(t *testing.T) { } fake := &pagingFake{} - h := workflowlist.NewHandlerWithClient(rtCtx, fake) + h := cmdlist.NewHandlerWithClient(rtCtx, fake) oldStdout := os.Stdout r, w, err := os.Pipe() @@ -800,8 +800,8 @@ func TestExecute_Pagination(t *testing.T) { _, _ = io.Copy(&buf, r) out := buf.String() - wantRows := testWorkflowListPageSize + 2 - if got := strings.Count(out, "0xo"); got < wantRows { + wantRows := workflowlist.DefaultPageSize + 2 + if got := strings.Count(out, "9292929292929292929292929292929292929292"); got < wantRows { t.Errorf("expected at least %d owner cells, got %d in:\n%s", wantRows, got, out) } if fake.call != 2 { diff --git a/cmd/workflow/list/print.go b/cmd/workflow/list/print.go new file mode 100644 index 00000000..9023a509 --- /dev/null +++ b/cmd/workflow/list/print.go @@ -0,0 +1,53 @@ +package list + +import ( + "fmt" + "strings" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowlist" +) + +func omitDeleted(rows []workflowlist.Workflow) []workflowlist.Workflow { + out := make([]workflowlist.Workflow, 0, len(rows)) + for _, r := range rows { + if strings.EqualFold(strings.TrimSpace(r.Status), "DELETED") { + continue + } + out = append(out, r) + } + return out +} + +func printWorkflowTable(rows []workflowlist.Workflow, registries []*tenantctx.Registry, afterRegistryFilter int, includeDeleted bool) { + ui.Line() + if len(rows) == 0 { + if afterRegistryFilter > 0 && !includeDeleted { + ui.Warning("No workflows found (excluding deleted). Use --include-deleted to list them.") + } else { + ui.Warning("No workflows found") + } + ui.Line() + return + } + + ui.Bold("Workflows") + ui.Line() + + for i, r := range rows { + matchedReg := resolveWorkflowRegistry(r.WorkflowSource, registries) + regIDCol := formatRegistryIDFromResolved(r.WorkflowSource, matchedReg) + ui.Bold(fmt.Sprintf("%d. %s", i+1, r.Name)) + ui.Dim(fmt.Sprintf(" Workflow ID: %s", r.WorkflowID)) + ui.Dim(fmt.Sprintf(" Owner: %s", r.OwnerAddress)) + ui.Dim(fmt.Sprintf(" Status: %s", r.Status)) + ui.Dim(fmt.Sprintf(" Registry: %s", regIDCol)) + if matchedReg != nil && registryEligibleForContractRows(matchedReg) && matchedReg.Address != nil { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(*matchedReg.Address))) + } else if _, addr, ok := parseContractWorkflowSource(r.WorkflowSource); ok && strings.TrimSpace(addr) != "" { + ui.Dim(fmt.Sprintf(" Address: %s", strings.TrimSpace(addr))) + } + ui.Line() + } +} diff --git a/cmd/workflow/list/registry.go b/cmd/workflow/list/registry.go new file mode 100644 index 00000000..3d91cfa5 --- /dev/null +++ b/cmd/workflow/list/registry.go @@ -0,0 +1,215 @@ +package list + +import ( + "strings" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" + "github.com/smartcontractkit/cre-cli/internal/workflowlist" +) + +// Registry matching: user context stores registry id +// plus chain_selector and address, while the list API returns workflowSource as +// contract::<0x…> or grpc:… — not the manifest id string. Direct equality with +// reg.ID therefore only applies when the API echoes the same id (e.g. "private"). + +func filterRowsByRegistry(rows []workflowlist.Workflow, reg *tenantctx.Registry, all []*tenantctx.Registry) []workflowlist.Workflow { + if reg == nil { + return rows + } + out := make([]workflowlist.Workflow, 0, len(rows)) + for _, r := range rows { + if rowMatchesRegistry(r.WorkflowSource, reg, all) { + out = append(out, r) + } + } + return out +} + +func rowMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { + if workflowSource == reg.ID { + return true + } + + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + return contractSourceMatchesRegistry(workflowSource, reg, all) + } + + if strings.HasPrefix(workflowSource, "grpc:") { + if !registryEligibleForGrpcRows(reg) { + return false + } + resolved := resolveGrpcSourceRegistry(workflowSource, all) + return resolved != nil && resolved.ID == reg.ID + } + + return false +} + +func registryTypeOffChain(reg *tenantctx.Registry) bool { + if reg == nil { + return false + } + t := strings.TrimSpace(strings.ReplaceAll(strings.ToLower(reg.Type), "_", "-")) + return t == "off-chain" || strings.EqualFold(strings.TrimSpace(reg.Type), "OFF_CHAIN") +} + +func hasContractAddress(reg *tenantctx.Registry) bool { + return reg != nil && reg.Address != nil && strings.TrimSpace(*reg.Address) != "" +} + +func registryEligibleForContractRows(reg *tenantctx.Registry) bool { + if reg == nil || !hasContractAddress(reg) { + return false + } + if registryTypeOffChain(reg) { + return false + } + return true +} + +func registryEligibleForGrpcRows(reg *tenantctx.Registry) bool { + if reg == nil { + return false + } + if registryTypeOffChain(reg) { + return true + } + if hasContractAddress(reg) { + return false + } + return true +} + +func contractSourceMatchesRegistry(workflowSource string, reg *tenantctx.Registry, all []*tenantctx.Registry) bool { + found := findContractRegistry(workflowSource, all) + return found != nil && found.ID == reg.ID +} + +func findContractRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { + const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return nil + } + rest := strings.TrimPrefix(workflowSource, contractPrefix) + selector, addr, ok := strings.Cut(rest, ":") + if !ok || addr == "" { + return nil + } + for _, r := range registries { + if !registryEligibleForContractRows(r) { + continue + } + if !addressesEqual(addr, *r.Address) { + continue + } + if r.ChainSelector != nil && strings.TrimSpace(*r.ChainSelector) != "" && + strings.TrimSpace(*r.ChainSelector) != strings.TrimSpace(selector) { + continue + } + return r + } + return nil +} + +func parseContractWorkflowSource(workflowSource string) (selector, addr string, ok bool) { + const contractPrefix = "contract:" + if !strings.HasPrefix(workflowSource, contractPrefix) { + return "", "", false + } + rest := strings.TrimPrefix(workflowSource, contractPrefix) + return strings.Cut(rest, ":") +} + +func resolveGrpcSourceRegistry(workflowSource string, all []*tenantctx.Registry) *tenantctx.Registry { + if !strings.HasPrefix(workflowSource, "grpc:") { + return nil + } + eligible := make([]*tenantctx.Registry, 0, len(all)) + for _, r := range all { + if r != nil && registryEligibleForGrpcRows(r) { + eligible = append(eligible, r) + } + } + if len(eligible) == 1 { + return eligible[0] + } + var match *tenantctx.Registry + for _, r := range eligible { + id := strings.ToLower(strings.TrimSpace(r.ID)) + if len(id) < 3 { + continue + } + if strings.Contains(strings.ToLower(workflowSource), id) { + if match != nil { + return nil + } + match = r + } + } + return match +} + +func resolveWorkflowRegistry(workflowSource string, registries []*tenantctx.Registry) *tenantctx.Registry { + byID := registryByWorkflowSource(registries) + if reg, ok := byID[workflowSource]; ok { + return reg + } + + if cr := findContractRegistry(workflowSource, registries); cr != nil { + return cr + } + const contractPrefix = "contract:" + if strings.HasPrefix(workflowSource, contractPrefix) { + return nil + } + + if strings.HasPrefix(workflowSource, "grpc:") { + return resolveGrpcSourceRegistry(workflowSource, registries) + } + + return nil +} + +func formatRegistryIDFromResolved(workflowSource string, matched *tenantctx.Registry) string { + if matched != nil { + return matched.ID + } + return workflowSource +} + +func addressesEqual(a, b string) bool { + return strings.EqualFold( + strings.TrimPrefix(strings.TrimSpace(a), "0x"), + strings.TrimPrefix(strings.TrimSpace(b), "0x"), + ) +} + +func registryByWorkflowSource(registries []*tenantctx.Registry) map[string]*tenantctx.Registry { + m := make(map[string]*tenantctx.Registry) + for _, r := range registries { + if r != nil { + m[r.ID] = r + } + } + return m +} + +func findRegistry(registries []*tenantctx.Registry, id string) *tenantctx.Registry { + for _, r := range registries { + if r != nil && r.ID == id { + return r + } + } + return nil +} + +func availableRegistryIDs(registries []*tenantctx.Registry) string { + ids := make([]string, 0, len(registries)) + for _, r := range registries { + if r != nil { + ids = append(ids, r.ID) + } + } + return strings.Join(ids, ", ") +} diff --git a/internal/workflowlist/client.go b/internal/workflowlist/client.go new file mode 100644 index 00000000..84c7e6fd --- /dev/null +++ b/internal/workflowlist/client.go @@ -0,0 +1,94 @@ +package workflowlist + +import ( + "context" + "fmt" + + "github.com/machinebox/graphql" +) + +const DefaultPageSize = 100 + +// Workflow is a workflow row from the platform list API, decoupled from transport JSON. +type Workflow struct { + Name string + WorkflowID string + OwnerAddress string + Status string + WorkflowSource string +} + +const listWorkflowsQuery = ` +query ListWorkflows($input: WorkflowsInput!) { + workflows(input: $input) { + data { + name + workflowId + ownerAddress + status + workflowSource + } + count + } +} +` + +// Executor runs a GraphQL request (e.g. graphqlclient.Client). +type Executor interface { + Execute(ctx context.Context, req *graphql.Request, resp any) error +} + +type gqlWorkflow struct { + Name string `json:"name"` + WorkflowID string `json:"workflowId"` + OwnerAddress string `json:"ownerAddress"` + Status string `json:"status"` + WorkflowSource string `json:"workflowSource"` +} + +type listWorkflowsEnvelope struct { + Workflows struct { + Data []gqlWorkflow `json:"data"` + Count int `json:"count"` + } `json:"workflows"` +} + +// ListAll pages through ListWorkflows and returns the aggregated workflows. +func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, error) { + if pageSize <= 0 { + pageSize = DefaultPageSize + } + + var total int + all := make([]Workflow, 0) + + for pageNum := 0; ; pageNum++ { + req := graphql.NewRequest(listWorkflowsQuery) + req.Var("input", map[string]any{ + "page": map[string]any{ + "number": pageNum, + "size": pageSize, + }, + }) + + var env listWorkflowsEnvelope + if err := exec.Execute(ctx, req, &env); err != nil { + return nil, fmt.Errorf("list workflows: %w", err) + } + + if pageNum == 0 { + total = env.Workflows.Count + } + + batch := env.Workflows.Data + for _, g := range batch { + all = append(all, Workflow(g)) + } + + if len(all) >= total || len(batch) == 0 { + break + } + } + + return all, nil +} diff --git a/internal/workflowlist/client_test.go b/internal/workflowlist/client_test.go new file mode 100644 index 00000000..f7aa3557 --- /dev/null +++ b/internal/workflowlist/client_test.go @@ -0,0 +1,83 @@ +package workflowlist_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/machinebox/graphql" + + "github.com/smartcontractkit/cre-cli/internal/workflowlist" +) + +type seqExecutor struct { + call int +} + +func (s *seqExecutor) Execute(ctx context.Context, req *graphql.Request, resp any) error { + s.call++ + var body []byte + var err error + switch s.call { + case 1: + data := make([]map[string]string, workflowlist.DefaultPageSize) + for i := range data { + data[i] = map[string]string{ + "name": "mock-wf-page", + "workflowId": "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0", + "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "status": "ACTIVE", + "workflowSource": "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee", + } + } + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": workflowlist.DefaultPageSize + 1, + "data": data, + }, + }) + case 2: + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{ + "count": workflowlist.DefaultPageSize + 1, + "data": []map[string]string{ + { + "name": "mock-wf-last", + "workflowId": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }) + default: + body, err = json.Marshal(map[string]any{ + "workflows": map[string]any{"count": workflowlist.DefaultPageSize + 1, "data": []any{}}, + }) + } + if err != nil { + return err + } + return json.Unmarshal(body, resp) +} + +func TestListAll_PaginatesAndMapsRows(t *testing.T) { + ex := &seqExecutor{} + got, err := workflowlist.ListAll(context.Background(), ex, workflowlist.DefaultPageSize) + if err != nil { + t.Fatal(err) + } + if len(got) != workflowlist.DefaultPageSize+1 { + t.Fatalf("got %d workflows, want %d", len(got), workflowlist.DefaultPageSize+1) + } + if got[0].WorkflowSource != "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee" { + t.Errorf("first row source: %q", got[0].WorkflowSource) + } + if got[len(got)-1].Name != "mock-wf-last" || got[len(got)-1].WorkflowSource != "private" { + t.Errorf("last row: %+v", got[len(got)-1]) + } + if ex.call != 2 { + t.Errorf("executor calls = %d, want 2", ex.call) + } +} From a8e07c52704a026f3fb67f69d8ab6c4ebcbc3401 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Thu, 23 Apr 2026 11:01:08 +0100 Subject: [PATCH 7/8] restructure --- cmd/workflow/list/list.go | 7 +++--- .../workflow/list/list_client.go | 10 ++++++-- .../workflow/list/list_client_test.go | 24 +++++++++---------- cmd/workflow/list/list_test.go | 11 ++++----- cmd/workflow/list/print.go | 7 +++--- cmd/workflow/list/registry.go | 5 ++-- 6 files changed, 33 insertions(+), 31 deletions(-) rename internal/workflowlist/client.go => cmd/workflow/list/list_client.go (89%) rename internal/workflowlist/client_test.go => cmd/workflow/list/list_client_test.go (70%) diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index 8caf3c6e..ad5edc67 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -11,14 +11,13 @@ import ( "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" - "github.com/smartcontractkit/cre-cli/internal/workflowlist" ) // Handler loads workflows via the list client and prints them. type Handler struct { credentials *credentials.Credentials tenantCtx *tenantctx.EnvironmentContext - gql workflowlist.Executor + gql Executor } // NewHandler builds a handler with the real GraphQL executor. @@ -27,7 +26,7 @@ func NewHandler(ctx *runtime.Context) *Handler { } // NewHandlerWithClient builds a handler with an optional GraphQL executor (nil uses graphqlclient.New). -func NewHandlerWithClient(ctx *runtime.Context, gql workflowlist.Executor) *Handler { +func NewHandlerWithClient(ctx *runtime.Context, gql Executor) *Handler { if gql == nil { gql = graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) } @@ -58,7 +57,7 @@ func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDel spinner := ui.NewSpinner() spinner.Start("Listing workflows...") - rows, err := workflowlist.ListAll(ctx, h.gql, workflowlist.DefaultPageSize) + rows, err := ListAll(ctx, h.gql, DefaultPageSize) spinner.Stop() if err != nil { return err diff --git a/internal/workflowlist/client.go b/cmd/workflow/list/list_client.go similarity index 89% rename from internal/workflowlist/client.go rename to cmd/workflow/list/list_client.go index 84c7e6fd..9b1e6771 100644 --- a/internal/workflowlist/client.go +++ b/cmd/workflow/list/list_client.go @@ -1,4 +1,4 @@ -package workflowlist +package list import ( "context" @@ -82,7 +82,13 @@ func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, erro batch := env.Workflows.Data for _, g := range batch { - all = append(all, Workflow(g)) + all = append(all, Workflow{ + Name: g.Name, + WorkflowID: g.WorkflowID, + OwnerAddress: g.OwnerAddress, + Status: g.Status, + WorkflowSource: g.WorkflowSource, + }) } if len(all) >= total || len(batch) == 0 { diff --git a/internal/workflowlist/client_test.go b/cmd/workflow/list/list_client_test.go similarity index 70% rename from internal/workflowlist/client_test.go rename to cmd/workflow/list/list_client_test.go index f7aa3557..3ae3e514 100644 --- a/internal/workflowlist/client_test.go +++ b/cmd/workflow/list/list_client_test.go @@ -1,4 +1,4 @@ -package workflowlist_test +package list_test import ( "context" @@ -7,20 +7,20 @@ import ( "github.com/machinebox/graphql" - "github.com/smartcontractkit/cre-cli/internal/workflowlist" + cmdlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" ) -type seqExecutor struct { +type listAllSeqExecutor struct { call int } -func (s *seqExecutor) Execute(ctx context.Context, req *graphql.Request, resp any) error { +func (s *listAllSeqExecutor) Execute(ctx context.Context, req *graphql.Request, resp any) error { s.call++ var body []byte var err error switch s.call { case 1: - data := make([]map[string]string, workflowlist.DefaultPageSize) + data := make([]map[string]string, cmdlist.DefaultPageSize) for i := range data { data[i] = map[string]string{ "name": "mock-wf-page", @@ -32,14 +32,14 @@ func (s *seqExecutor) Execute(ctx context.Context, req *graphql.Request, resp an } body, err = json.Marshal(map[string]any{ "workflows": map[string]any{ - "count": workflowlist.DefaultPageSize + 1, + "count": cmdlist.DefaultPageSize + 1, "data": data, }, }) case 2: body, err = json.Marshal(map[string]any{ "workflows": map[string]any{ - "count": workflowlist.DefaultPageSize + 1, + "count": cmdlist.DefaultPageSize + 1, "data": []map[string]string{ { "name": "mock-wf-last", @@ -53,7 +53,7 @@ func (s *seqExecutor) Execute(ctx context.Context, req *graphql.Request, resp an }) default: body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{"count": workflowlist.DefaultPageSize + 1, "data": []any{}}, + "workflows": map[string]any{"count": cmdlist.DefaultPageSize + 1, "data": []any{}}, }) } if err != nil { @@ -63,13 +63,13 @@ func (s *seqExecutor) Execute(ctx context.Context, req *graphql.Request, resp an } func TestListAll_PaginatesAndMapsRows(t *testing.T) { - ex := &seqExecutor{} - got, err := workflowlist.ListAll(context.Background(), ex, workflowlist.DefaultPageSize) + ex := &listAllSeqExecutor{} + got, err := cmdlist.ListAll(context.Background(), ex, cmdlist.DefaultPageSize) if err != nil { t.Fatal(err) } - if len(got) != workflowlist.DefaultPageSize+1 { - t.Fatalf("got %d workflows, want %d", len(got), workflowlist.DefaultPageSize+1) + if len(got) != cmdlist.DefaultPageSize+1 { + t.Fatalf("got %d workflows, want %d", len(got), cmdlist.DefaultPageSize+1) } if got[0].WorkflowSource != "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee" { t.Errorf("first row source: %q", got[0].WorkflowSource) diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index d121e3a3..8fb00781 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -16,7 +16,6 @@ import ( "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" - "github.com/smartcontractkit/cre-cli/internal/workflowlist" ) func strPtr(s string) *string { return &s } @@ -719,7 +718,7 @@ func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any var err error switch f.call { case 1: - data := make([]map[string]string, workflowlist.DefaultPageSize) + data := make([]map[string]string, cmdlist.DefaultPageSize) for i := range data { data[i] = map[string]string{ "name": "wf-page-batch", @@ -731,14 +730,14 @@ func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any } body, err = json.Marshal(map[string]any{ "workflows": map[string]any{ - "count": workflowlist.DefaultPageSize + 2, + "count": cmdlist.DefaultPageSize + 2, "data": data, }, }) case 2: body, err = json.Marshal(map[string]any{ "workflows": map[string]any{ - "count": workflowlist.DefaultPageSize + 2, + "count": cmdlist.DefaultPageSize + 2, "data": []map[string]string{ { "name": "wf-page-tail-1", @@ -759,7 +758,7 @@ func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any }) default: body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{"count": workflowlist.DefaultPageSize + 2, "data": []any{}}, + "workflows": map[string]any{"count": cmdlist.DefaultPageSize + 2, "data": []any{}}, }) } if err != nil { @@ -800,7 +799,7 @@ func TestExecute_Pagination(t *testing.T) { _, _ = io.Copy(&buf, r) out := buf.String() - wantRows := workflowlist.DefaultPageSize + 2 + wantRows := cmdlist.DefaultPageSize + 2 if got := strings.Count(out, "9292929292929292929292929292929292929292"); got < wantRows { t.Errorf("expected at least %d owner cells, got %d in:\n%s", wantRows, got, out) } diff --git a/cmd/workflow/list/print.go b/cmd/workflow/list/print.go index 9023a509..42a53cd1 100644 --- a/cmd/workflow/list/print.go +++ b/cmd/workflow/list/print.go @@ -6,11 +6,10 @@ import ( "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" - "github.com/smartcontractkit/cre-cli/internal/workflowlist" ) -func omitDeleted(rows []workflowlist.Workflow) []workflowlist.Workflow { - out := make([]workflowlist.Workflow, 0, len(rows)) +func omitDeleted(rows []Workflow) []Workflow { + out := make([]Workflow, 0, len(rows)) for _, r := range rows { if strings.EqualFold(strings.TrimSpace(r.Status), "DELETED") { continue @@ -20,7 +19,7 @@ func omitDeleted(rows []workflowlist.Workflow) []workflowlist.Workflow { return out } -func printWorkflowTable(rows []workflowlist.Workflow, registries []*tenantctx.Registry, afterRegistryFilter int, includeDeleted bool) { +func printWorkflowTable(rows []Workflow, registries []*tenantctx.Registry, afterRegistryFilter int, includeDeleted bool) { ui.Line() if len(rows) == 0 { if afterRegistryFilter > 0 && !includeDeleted { diff --git a/cmd/workflow/list/registry.go b/cmd/workflow/list/registry.go index 3d91cfa5..9de98901 100644 --- a/cmd/workflow/list/registry.go +++ b/cmd/workflow/list/registry.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/smartcontractkit/cre-cli/internal/tenantctx" - "github.com/smartcontractkit/cre-cli/internal/workflowlist" ) // Registry matching: user context stores registry id @@ -12,11 +11,11 @@ import ( // contract::<0x…> or grpc:… — not the manifest id string. Direct equality with // reg.ID therefore only applies when the API echoes the same id (e.g. "private"). -func filterRowsByRegistry(rows []workflowlist.Workflow, reg *tenantctx.Registry, all []*tenantctx.Registry) []workflowlist.Workflow { +func filterRowsByRegistry(rows []Workflow, reg *tenantctx.Registry, all []*tenantctx.Registry) []Workflow { if reg == nil { return rows } - out := make([]workflowlist.Workflow, 0, len(rows)) + out := make([]Workflow, 0, len(rows)) for _, r := range rows { if rowMatchesRegistry(r.WorkflowSource, reg, all) { out = append(out, r) From d133a9da26f5b0e34e4a521a309bac37c45aefc7 Mon Sep 17 00:00:00 2001 From: timothyF95 Date: Thu, 23 Apr 2026 15:46:56 +0100 Subject: [PATCH 8/8] Review feedback --- cmd/workflow/list/list.go | 30 +- cmd/workflow/list/list_client_test.go | 83 -- cmd/workflow/list/list_test.go | 708 +++++++----------- cmd/workflow/list/registry_test.go | 126 ++++ .../workflowdataclient/workflowdataclient.go | 38 +- .../workflowdataclient_test.go | 149 ++++ 6 files changed, 583 insertions(+), 551 deletions(-) delete mode 100644 cmd/workflow/list/list_client_test.go create mode 100644 cmd/workflow/list/registry_test.go rename cmd/workflow/list/list_client.go => internal/client/workflowdataclient/workflowdataclient.go (63%) create mode 100644 internal/client/workflowdataclient/workflowdataclient_test.go diff --git a/cmd/workflow/list/list.go b/cmd/workflow/list/list.go index ad5edc67..12e732f1 100644 --- a/cmd/workflow/list/list.go +++ b/cmd/workflow/list/list.go @@ -7,33 +7,41 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/tenantctx" "github.com/smartcontractkit/cre-cli/internal/ui" ) -// Handler loads workflows via the list client and prints them. +// Workflow is a type alias so that print.go and registry.go in this package +// can use the name without importing workflowdataclient directly. +type Workflow = workflowdataclient.Workflow + +// Handler loads workflows via the WorkflowDataClient and prints them. type Handler struct { credentials *credentials.Credentials tenantCtx *tenantctx.EnvironmentContext - gql Executor + wdc *workflowdataclient.Client } -// NewHandler builds a handler with the real GraphQL executor. +// NewHandler builds a Handler with a real WorkflowDataClient. func NewHandler(ctx *runtime.Context) *Handler { - return NewHandlerWithClient(ctx, nil) + gql := graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) + wdc := workflowdataclient.New(gql, ctx.Logger) + return &Handler{ + credentials: ctx.Credentials, + tenantCtx: ctx.TenantContext, + wdc: wdc, + } } -// NewHandlerWithClient builds a handler with an optional GraphQL executor (nil uses graphqlclient.New). -func NewHandlerWithClient(ctx *runtime.Context, gql Executor) *Handler { - if gql == nil { - gql = graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger) - } +// NewHandlerWithClient builds a Handler with a pre-built WorkflowDataClient (for testing). +func NewHandlerWithClient(ctx *runtime.Context, wdc *workflowdataclient.Client) *Handler { return &Handler{ credentials: ctx.Credentials, tenantCtx: ctx.TenantContext, - gql: gql, + wdc: wdc, } } @@ -57,7 +65,7 @@ func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDel spinner := ui.NewSpinner() spinner.Start("Listing workflows...") - rows, err := ListAll(ctx, h.gql, DefaultPageSize) + rows, err := h.wdc.ListAll(ctx, workflowdataclient.DefaultPageSize) spinner.Stop() if err != nil { return err diff --git a/cmd/workflow/list/list_client_test.go b/cmd/workflow/list/list_client_test.go deleted file mode 100644 index 3ae3e514..00000000 --- a/cmd/workflow/list/list_client_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package list_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/machinebox/graphql" - - cmdlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" -) - -type listAllSeqExecutor struct { - call int -} - -func (s *listAllSeqExecutor) Execute(ctx context.Context, req *graphql.Request, resp any) error { - s.call++ - var body []byte - var err error - switch s.call { - case 1: - data := make([]map[string]string, cmdlist.DefaultPageSize) - for i := range data { - data[i] = map[string]string{ - "name": "mock-wf-page", - "workflowId": "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0", - "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", - "status": "ACTIVE", - "workflowSource": "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee", - } - } - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": cmdlist.DefaultPageSize + 1, - "data": data, - }, - }) - case 2: - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": cmdlist.DefaultPageSize + 1, - "data": []map[string]string{ - { - "name": "mock-wf-last", - "workflowId": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", - "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", - "status": "ACTIVE", - "workflowSource": "private", - }, - }, - }, - }) - default: - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{"count": cmdlist.DefaultPageSize + 1, "data": []any{}}, - }) - } - if err != nil { - return err - } - return json.Unmarshal(body, resp) -} - -func TestListAll_PaginatesAndMapsRows(t *testing.T) { - ex := &listAllSeqExecutor{} - got, err := cmdlist.ListAll(context.Background(), ex, cmdlist.DefaultPageSize) - if err != nil { - t.Fatal(err) - } - if len(got) != cmdlist.DefaultPageSize+1 { - t.Fatalf("got %d workflows, want %d", len(got), cmdlist.DefaultPageSize+1) - } - if got[0].WorkflowSource != "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee" { - t.Errorf("first row source: %q", got[0].WorkflowSource) - } - if got[len(got)-1].Name != "mock-wf-last" || got[len(got)-1].WorkflowSource != "private" { - t.Errorf("last row: %+v", got[len(got)-1]) - } - if ex.call != 2 { - t.Errorf("executor calls = %d, want 2", ex.call) - } -} diff --git a/cmd/workflow/list/list_test.go b/cmd/workflow/list/list_test.go index 8fb00781..016093c7 100644 --- a/cmd/workflow/list/list_test.go +++ b/cmd/workflow/list/list_test.go @@ -4,14 +4,18 @@ import ( "context" "encoding/json" "io" + "net/http" + "net/http/httptest" "os" "strings" + "sync/atomic" "testing" - "github.com/machinebox/graphql" "github.com/rs/zerolog" cmdlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list" + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/client/workflowdataclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -20,6 +24,100 @@ import ( func strPtr(s string) *string { return &s } +// newWorkflowServer starts an httptest.Server that responds to ListWorkflows +// with the provided pages of workflow data (each call advances through pages). +func newWorkflowServer(t *testing.T, pages [][]map[string]string, totalCount int) *httptest.Server { + t.Helper() + var call atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idx := int(call.Add(1)) - 1 + w.Header().Set("Content-Type", "application/json") + var data []map[string]string + if idx < len(pages) { + data = pages[idx] + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": totalCount, + "data": data, + }, + }, + }) + })) + return srv +} + +// newHandlerWithServer builds a Handler wired to an httptest.Server. +func newHandlerWithServer(t *testing.T, rtCtx *runtime.Context, srv *httptest.Server) *cmdlist.Handler { + t.Helper() + logger := zerolog.Nop() + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + envSet := &environments.EnvironmentSet{GraphQLURL: srv.URL} + gql := graphqlclient.New(creds, envSet, &logger) + wdc := workflowdataclient.New(gql, &logger) + return cmdlist.NewHandlerWithClient(rtCtx, wdc) +} + +// threeWorkflowPage returns the two-active-one-deleted page used across several tests. +func threeWorkflowPage() []map[string]string { + return []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "beta", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "PAUSED", + "workflowSource": "contract:999888777666555444333:0xabababababababababababababababababababab", + }, + { + "name": "gone-deleted", + "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", + "ownerAddress": "6060606060606060606060606060606060606060", + "status": "DELETED", + "workflowSource": "private", + }, + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stderr + os.Stderr = w + fn() + w.Close() + os.Stderr = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + func TestNew_NoTenantContext(t *testing.T) { logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ @@ -101,59 +199,6 @@ func TestNew_RejectsArgs(t *testing.T) { } } -type fakeGQL struct { - call int -} - -func (f *fakeGQL) Execute(ctx context.Context, req *graphql.Request, resp any) error { - f.call++ - var body []byte - var err error - - switch f.call { - case 1: - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": 3, - "data": []map[string]string{ - { - "name": "alpha", - "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", - "ownerAddress": "2020202020202020202020202020202020202020", - "status": "ACTIVE", - "workflowSource": "private", - }, - { - "name": "beta", - "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", - "ownerAddress": "4040404040404040404040404040404040404040", - "status": "PAUSED", - "workflowSource": "contract:999888777666555444333:0xabababababababababababababababababababab", - }, - { - "name": "gone-deleted", - "workflowId": "5050505050505050505050505050505050505050505050505050505050505050", - "ownerAddress": "6060606060606060606060606060606060606060", - "status": "DELETED", - "workflowSource": "private", - }, - }, - }, - }) - default: - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": 3, - "data": []any{}, - }, - }) - } - if err != nil { - return err - } - return json.Unmarshal(body, resp) -} - func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ @@ -167,26 +212,15 @@ func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { }, } - fake := &fakeGQL{} - h := cmdlist.NewHandlerWithClient(rtCtx, fake) + srv := newWorkflowServer(t, [][]map[string]string{threeWorkflowPage()}, 3) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } - - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) if strings.Contains(out, "gone-deleted") { t.Errorf("deleted workflow should be omitted by default; output:\n%s", out) @@ -214,9 +248,6 @@ func TestExecute_WithMock_PrintsWorkflowBlocks(t *testing.T) { t.Errorf("output missing %q:\n%s", want, out) } } - if fake.call != 1 { - t.Errorf("expected single GQL page, got %d calls", fake.call) - } } func TestExecute_WithMock_IncludeDeleted(t *testing.T) { @@ -230,26 +261,15 @@ func TestExecute_WithMock_IncludeDeleted(t *testing.T) { }, } - fake := &fakeGQL{} - h := cmdlist.NewHandlerWithClient(rtCtx, fake) - - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "", true) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } + srv := newWorkflowServer(t, [][]map[string]string{threeWorkflowPage()}, 3) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", true); err != nil { + t.Fatal(err) + } + }) if !strings.Contains(out, "gone-deleted") || !strings.Contains(out, "DELETED") { t.Errorf("expected deleted workflow with --include-deleted; output:\n%s", out) @@ -265,66 +285,31 @@ func TestExecute_AllDeletedShowsHint(t *testing.T) { TenantContext: &tenantctx.EnvironmentContext{Registries: []*tenantctx.Registry{}}, } - deletedOnly := &fakeGQLDeletedOnly{} - h := cmdlist.NewHandlerWithClient(rtCtx, deletedOnly) - - oldStdout := os.Stdout - sr, sw, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = sw - - oldStderr := os.Stderr - er, ew, err := os.Pipe() - if err != nil { - sw.Close() - os.Stdout = oldStdout - t.Fatal(err) - } - os.Stderr = ew - - err = h.Execute(context.Background(), "", false) - sw.Close() - ew.Close() - os.Stdout = oldStdout - os.Stderr = oldStderr - if err != nil { - t.Fatal(err) + deletedPage := []map[string]string{ + { + "name": "gone-deleted-only", + "workflowId": "7070707070707070707070707070707070707070707070707070707070707070", + "ownerAddress": "8080808080808080808080808080808080808080", + "status": "DELETED", + "workflowSource": "private", + }, } + srv := newWorkflowServer(t, [][]map[string]string{deletedPage}, 1) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - var stderrBuf strings.Builder - _, _ = io.Copy(&stderrBuf, er) - errOut := stderrBuf.String() + var errOut string + captureStdout(t, func() { + errOut = captureStderr(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + }) if !strings.Contains(errOut, "excluding deleted") || !strings.Contains(errOut, "--include-deleted") { t.Errorf("expected hint on stderr when all workflows are deleted; stderr:\n%s", errOut) } - - _, _ = io.Copy(io.Discard, sr) -} - -type fakeGQLDeletedOnly struct{} - -func (f *fakeGQLDeletedOnly) Execute(ctx context.Context, req *graphql.Request, resp any) error { - body, err := json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": 1, - "data": []map[string]string{ - { - "name": "gone-deleted-only", - "workflowId": "7070707070707070707070707070707070707070707070707070707070707070", - "ownerAddress": "8080808080808080808080808080808080808080", - "status": "DELETED", - "workflowSource": "private", - }, - }, - }, - }) - if err != nil { - return err - } - return json.Unmarshal(body, resp) } func TestExecute_WithMock_RegistryFilter(t *testing.T) { @@ -338,106 +323,74 @@ func TestExecute_WithMock_RegistryFilter(t *testing.T) { }, } - fake := &fakeGQL{} - h := cmdlist.NewHandlerWithClient(rtCtx, fake) - - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "private", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } + srv := newWorkflowServer(t, [][]map[string]string{threeWorkflowPage()}, 3) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "private", false); err != nil { + t.Fatal(err) + } + }) if !strings.Contains(out, "alpha") || strings.Contains(out, "beta") { t.Errorf("expected only private registry row; output:\n%s", out) } } -// fakeGQLMixedRegistries returns one on-chain (contract:…) and one grpc workflow. -type fakeGQLMixedRegistries struct{} +func mixedRegistriesPage() []map[string]string { + return []map[string]string{ + { + "name": "onchain-wf", + "workflowId": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "ownerAddress": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "status": "ACTIVE", + "workflowSource": "contract:12345678901234567890:0xcafebabe00000000000000000000000000feed", + }, + { + "name": "grpc-wf", + "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "status": "ACTIVE", + "workflowSource": "grpc:mock-private-registry:v1", + }, + } +} -func (f *fakeGQLMixedRegistries) Execute(ctx context.Context, req *graphql.Request, resp any) error { +func mixedRegistriesContext() *tenantctx.EnvironmentContext { chainSel := "12345678901234567890" addr := "0xcafebabe00000000000000000000000000feed" - body, err := json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": 2, - "data": []map[string]string{ - { - "name": "onchain-wf", - "workflowId": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", - "ownerAddress": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", - "status": "ACTIVE", - "workflowSource": "contract:" + chainSel + ":" + addr, - }, - { - "name": "grpc-wf", - "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", - "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", - "status": "ACTIVE", - "workflowSource": "grpc:mock-private-registry:v1", - }, + return &tenantctx.EnvironmentContext{ + Registries: []*tenantctx.Registry{ + { + ID: "onchain:mock-testnet", + Label: "mock-testnet (short)", + ChainSelector: strPtr(chainSel), + Address: strPtr(addr), }, + {ID: "private", Label: "Private", Type: "off-chain"}, }, - }) - if err != nil { - return err } - return json.Unmarshal(body, resp) } func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { - chainSel := "12345678901234567890" - addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, Credentials: &credentials.Credentials{}, EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, - TenantContext: &tenantctx.EnvironmentContext{ - Registries: []*tenantctx.Registry{ - { - ID: "onchain:mock-testnet", - Label: "mock-testnet (short)", - // type often omitted in user context; matching uses address + chain selector - ChainSelector: strPtr(chainSel), - Address: strPtr(addr), - }, - {ID: "private", Label: "Private", Type: "off-chain"}, - }, - }, - } - - h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) - - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) + TenantContext: mixedRegistriesContext(), } - os.Stdout = w - err = h.Execute(context.Background(), "onchain:mock-testnet", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } + srv := newWorkflowServer(t, [][]map[string]string{mixedRegistriesPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "onchain:mock-testnet", false); err != nil { + t.Fatal(err) + } + }) if !strings.Contains(out, "onchain-wf") || strings.Contains(out, "grpc-wf") { t.Errorf("expected only contract-registry workflow; output:\n%s", out) @@ -445,45 +398,23 @@ func TestExecute_RegistryFilter_MatchesContractSource(t *testing.T) { } func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { - chainSel := "12345678901234567890" - addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, Credentials: &credentials.Credentials{}, EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, - TenantContext: &tenantctx.EnvironmentContext{ - Registries: []*tenantctx.Registry{ - { - ID: "onchain:mock-testnet", - Label: "mock", - ChainSelector: strPtr(chainSel), - Address: strPtr(addr), - }, - {ID: "private", Label: "Private hosted"}, - }, - }, + TenantContext: mixedRegistriesContext(), } - h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) - - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "private", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } + srv := newWorkflowServer(t, [][]map[string]string{mixedRegistriesPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "private", false); err != nil { + t.Fatal(err) + } + }) if !strings.Contains(out, "grpc-wf") || strings.Contains(out, "onchain-wf") { t.Errorf("expected only grpc/private-registry workflow; output:\n%s", out) @@ -491,47 +422,24 @@ func TestExecute_RegistryFilter_MatchesGrpcSource(t *testing.T) { } func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { - chainSel := "12345678901234567890" - addr := "0xcafebabe00000000000000000000000000feed" logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ Logger: &logger, Credentials: &credentials.Credentials{}, EnvironmentSet: &environments.EnvironmentSet{EnvName: "STAGING"}, - TenantContext: &tenantctx.EnvironmentContext{ - Registries: []*tenantctx.Registry{ - { - ID: "onchain:mock-testnet", - Label: "mock", - ChainSelector: strPtr(chainSel), - Address: strPtr(addr), - }, - {ID: "private", Label: "Private hosted"}, - }, - }, + TenantContext: mixedRegistriesContext(), } - h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLMixedRegistries{}) + srv := newWorkflowServer(t, [][]map[string]string{mixedRegistriesPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } - - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) - // Resolved grpc maps to context "private"; unresolved grpc would print the raw API source. if strings.Contains(out, "grpc:mock-private-registry:v1") { t.Errorf("expected resolved grpc to show context registry id, not raw API source; output:\n%s", out) } @@ -544,7 +452,7 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { end = len(out) } if !strings.Contains(out[idx:end], "Registry: private") { - t.Errorf("expected registry private (as in cre registry list) near grpc-wf block; output:\n%s", out) + t.Errorf("expected registry private near grpc-wf block; output:\n%s", out) } if strings.Contains(out[idx:end], "Address:") { t.Errorf("did not expect Address line for off-chain/grpc workflow; output:\n%s", out) @@ -559,47 +467,33 @@ func TestExecute_List_ShowsRegistryIDForGrpcSource(t *testing.T) { endOn = len(out) } onChunk := out[idxOn:endOn] - if !strings.Contains(onChunk, "onchain:mock-testnet") || - !strings.Contains(onChunk, "Registry:") { - t.Errorf("expected on-chain registry as in cre registry list near onchain-wf block; output:\n%s", out) + if !strings.Contains(onChunk, "onchain:mock-testnet") || !strings.Contains(onChunk, "Registry:") { + t.Errorf("expected on-chain registry near onchain-wf block; output:\n%s", out) } - if !strings.Contains(onChunk, "Address:") || - !strings.Contains(onChunk, "0xcafebabe00000000000000000000000000feed") { - t.Errorf("expected full registry Address line for on-chain workflow; output:\n%s", onChunk) + if !strings.Contains(onChunk, "Address:") || !strings.Contains(onChunk, "0xcafebabe00000000000000000000000000feed") { + t.Errorf("expected Address line for on-chain workflow; output:\n%s", onChunk) } } -// fakeGQLOrphanContractAndGrpc: contract address not in user context + one grpc workflow. -type fakeGQLOrphanContractAndGrpc struct{} - -func (f *fakeGQLOrphanContractAndGrpc) Execute(ctx context.Context, req *graphql.Request, resp any) error { +func orphanAndGrpcPage() []map[string]string { chainSel := "12345678901234567890" orphanAddr := "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - body, err := json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": 2, - "data": []map[string]string{ - { - "name": "orphan-onchain", - "workflowId": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1", - "ownerAddress": "e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2", - "status": "ACTIVE", - "workflowSource": "contract:" + chainSel + ":" + orphanAddr, - }, - { - "name": "grpc-wf", - "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", - "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", - "status": "ACTIVE", - "workflowSource": "grpc:mock-private-registry:v1", - }, - }, + return []map[string]string{ + { + "name": "orphan-onchain", + "workflowId": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1", + "ownerAddress": "e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2", + "status": "ACTIVE", + "workflowSource": "contract:" + chainSel + ":" + orphanAddr, + }, + { + "name": "grpc-wf", + "workflowId": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "ownerAddress": "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4", + "status": "ACTIVE", + "workflowSource": "grpc:mock-private-registry:v1", }, - }) - if err != nil { - return err } - return json.Unmarshal(body, resp) } func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { @@ -623,25 +517,15 @@ func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { }, } - h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) - - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } + srv := newWorkflowServer(t, [][]map[string]string{orphanAndGrpcPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) wantSource := "contract:" + chainSel + ":0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" idx := strings.Index(out, "orphan-onchain") @@ -656,8 +540,7 @@ func TestExecute_List_UnmatchedContractShowsAPISource(t *testing.T) { if !strings.Contains(chunk, "Registry: "+wantSource) { t.Errorf("expected unmatched contract to show API workflowSource in Registry line; chunk:\n%s", chunk) } - if !strings.Contains(chunk, "Address:") || - !strings.Contains(chunk, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { + if !strings.Contains(chunk, "Address:") || !strings.Contains(chunk, "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") { t.Errorf("expected Address from workflow source for orphan contract; chunk:\n%s", chunk) } } @@ -683,90 +566,21 @@ func TestExecute_RegistryFilter_PrivateExcludesUnmatchedContract(t *testing.T) { }, } - h := cmdlist.NewHandlerWithClient(rtCtx, &fakeGQLOrphanContractAndGrpc{}) + srv := newWorkflowServer(t, [][]map[string]string{orphanAndGrpcPage()}, 2) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdout = w - - err = h.Execute(context.Background(), "private", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) - } - - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "private", false); err != nil { + t.Fatal(err) + } + }) if !strings.Contains(out, "grpc-wf") || strings.Contains(out, "orphan-onchain") { t.Errorf("expected private filter to include only grpc workflows resolved to private, not unmatched contract rows; output:\n%s", out) } } -type pagingFake struct { - call int -} - -func (f *pagingFake) Execute(ctx context.Context, req *graphql.Request, resp any) error { - f.call++ - var body []byte - var err error - switch f.call { - case 1: - data := make([]map[string]string, cmdlist.DefaultPageSize) - for i := range data { - data[i] = map[string]string{ - "name": "wf-page-batch", - "workflowId": "9191919191919191919191919191919191919191919191919191919191919191", - "ownerAddress": "9292929292929292929292929292929292929292", - "status": "ACTIVE", - "workflowSource": "private", - } - } - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": cmdlist.DefaultPageSize + 2, - "data": data, - }, - }) - case 2: - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{ - "count": cmdlist.DefaultPageSize + 2, - "data": []map[string]string{ - { - "name": "wf-page-tail-1", - "workflowId": "9393939393939393939393939393939393939393939393939393939393939393", - "ownerAddress": "9292929292929292929292929292929292929292", - "status": "ACTIVE", - "workflowSource": "private", - }, - { - "name": "wf-page-tail-2", - "workflowId": "9494949494949494949494949494949494949494949494949494949494949494", - "ownerAddress": "9292929292929292929292929292929292929292", - "status": "ACTIVE", - "workflowSource": "private", - }, - }, - }, - }) - default: - body, err = json.Marshal(map[string]any{ - "workflows": map[string]any{"count": cmdlist.DefaultPageSize + 2, "data": []any{}}, - }) - } - if err != nil { - return err - } - return json.Unmarshal(body, resp) -} - func TestExecute_Pagination(t *testing.T) { logger := zerolog.New(io.Discard) rtCtx := &runtime.Context{ @@ -778,32 +592,46 @@ func TestExecute_Pagination(t *testing.T) { }, } - fake := &pagingFake{} - h := cmdlist.NewHandlerWithClient(rtCtx, fake) - - oldStdout := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) + page1 := make([]map[string]string, workflowdataclient.DefaultPageSize) + for i := range page1 { + page1[i] = map[string]string{ + "name": "wf-page-batch", + "workflowId": "9191919191919191919191919191919191919191919191919191919191919191", + "ownerAddress": "9292929292929292929292929292929292929292", + "status": "ACTIVE", + "workflowSource": "private", + } } - os.Stdout = w - - err = h.Execute(context.Background(), "", false) - w.Close() - os.Stdout = oldStdout - if err != nil { - t.Fatal(err) + page2 := []map[string]string{ + { + "name": "wf-page-tail-1", + "workflowId": "9393939393939393939393939393939393939393939393939393939393939393", + "ownerAddress": "9292929292929292929292929292929292929292", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "wf-page-tail-2", + "workflowId": "9494949494949494949494949494949494949494949494949494949494949494", + "ownerAddress": "9292929292929292929292929292929292929292", + "status": "ACTIVE", + "workflowSource": "private", + }, } - var buf strings.Builder - _, _ = io.Copy(&buf, r) - out := buf.String() + total := workflowdataclient.DefaultPageSize + 2 + srv := newWorkflowServer(t, [][]map[string]string{page1, page2}, total) + defer srv.Close() + h := newHandlerWithServer(t, rtCtx, srv) - wantRows := cmdlist.DefaultPageSize + 2 + out := captureStdout(t, func() { + if err := h.Execute(context.Background(), "", false); err != nil { + t.Fatal(err) + } + }) + + wantRows := workflowdataclient.DefaultPageSize + 2 if got := strings.Count(out, "9292929292929292929292929292929292929292"); got < wantRows { t.Errorf("expected at least %d owner cells, got %d in:\n%s", wantRows, got, out) } - if fake.call != 2 { - t.Errorf("expected 2 GQL calls, got %d", fake.call) - } } diff --git a/cmd/workflow/list/registry_test.go b/cmd/workflow/list/registry_test.go new file mode 100644 index 00000000..2272e4ca --- /dev/null +++ b/cmd/workflow/list/registry_test.go @@ -0,0 +1,126 @@ +package list + +import ( + "testing" + + "github.com/smartcontractkit/cre-cli/internal/tenantctx" +) + +func sp(s string) *string { return &s } + +func TestRowMatchesRegistry_DirectIDMatch(t *testing.T) { + reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{reg} + + if !rowMatchesRegistry("private", reg, all) { + t.Error("expected direct ID match to return true") + } + if rowMatchesRegistry("other", reg, all) { + t.Error("expected non-matching ID to return false") + } +} + +func TestRowMatchesRegistry_ContractSource_MatchesByAddress(t *testing.T) { + chainSel := "12345678901234567890" + addr := "0xdeadbeef00000000000000000000000000000000" + reg := &tenantctx.Registry{ + ID: "onchain:testnet", + ChainSelector: sp(chainSel), + Address: sp(addr), + } + all := []*tenantctx.Registry{reg, {ID: "private", Type: "off-chain"}} + + source := "contract:" + chainSel + ":" + addr + if !rowMatchesRegistry(source, reg, all) { + t.Errorf("expected contract source %q to match registry with address %q", source, addr) + } +} + +func TestRowMatchesRegistry_ContractSource_AddressCaseInsensitive(t *testing.T) { + chainSel := "99999999999999999999" + addr := "0xABCDEF0000000000000000000000000000000000" + reg := &tenantctx.Registry{ + ID: "onchain:case-test", + ChainSelector: sp(chainSel), + Address: sp(addr), + } + all := []*tenantctx.Registry{reg} + + lowerSource := "contract:" + chainSel + ":0xabcdef0000000000000000000000000000000000" + if !rowMatchesRegistry(lowerSource, reg, all) { + t.Errorf("expected case-insensitive address match for source %q", lowerSource) + } +} + +func TestRowMatchesRegistry_ContractSource_WrongChainSelector(t *testing.T) { + addr := "0xdeadbeef00000000000000000000000000000000" + reg := &tenantctx.Registry{ + ID: "onchain:testnet", + ChainSelector: sp("11111111111111111111"), + Address: sp(addr), + } + all := []*tenantctx.Registry{reg} + + source := "contract:99999999999999999999:" + addr + if rowMatchesRegistry(source, reg, all) { + t.Errorf("expected wrong chain selector to NOT match") + } +} + +func TestRowMatchesRegistry_ContractSource_NoMatchForOffChainRegistry(t *testing.T) { + offChain := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{offChain} + + source := "contract:12345678901234567890:0xdeadbeef00000000000000000000000000000000" + if rowMatchesRegistry(source, offChain, all) { + t.Error("expected contract source to NOT match an off-chain registry") + } +} + +func TestRowMatchesRegistry_GrpcSource_SingleEligibleRegistry(t *testing.T) { + private := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{ + {ID: "onchain:testnet", ChainSelector: sp("12345678901234567890"), Address: sp("0xaaaa")}, + private, + } + + if !rowMatchesRegistry("grpc:some-endpoint:v1", private, all) { + t.Error("expected grpc source to match the single eligible off-chain registry") + } +} + +func TestRowMatchesRegistry_GrpcSource_NoMatchForOnChainRegistry(t *testing.T) { + onchain := &tenantctx.Registry{ + ID: "onchain:testnet", + ChainSelector: sp("12345678901234567890"), + Address: sp("0xdeadbeef00000000000000000000000000000000"), + } + all := []*tenantctx.Registry{onchain} + + if rowMatchesRegistry("grpc:some-endpoint:v1", onchain, all) { + t.Error("expected grpc source to NOT match an on-chain registry") + } +} + +func TestRowMatchesRegistry_GrpcSource_MatchByIDSubstring(t *testing.T) { + reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + other := &tenantctx.Registry{ID: "staging", Type: "off-chain"} + all := []*tenantctx.Registry{reg, other} + + // Contains "private" in the source — should resolve to reg. + if !rowMatchesRegistry("grpc:private-endpoint:v1", reg, all) { + t.Error("expected grpc source containing registry ID substring to match that registry") + } + if rowMatchesRegistry("grpc:private-endpoint:v1", other, all) { + t.Error("expected grpc source to NOT match the non-matching registry") + } +} + +func TestRowMatchesRegistry_UnknownSourceFormat(t *testing.T) { + reg := &tenantctx.Registry{ID: "private", Type: "off-chain"} + all := []*tenantctx.Registry{reg} + + if rowMatchesRegistry("unknown:format:xyz", reg, all) { + t.Error("expected unknown source format to NOT match any registry") + } +} diff --git a/cmd/workflow/list/list_client.go b/internal/client/workflowdataclient/workflowdataclient.go similarity index 63% rename from cmd/workflow/list/list_client.go rename to internal/client/workflowdataclient/workflowdataclient.go index 9b1e6771..1af3f272 100644 --- a/cmd/workflow/list/list_client.go +++ b/internal/client/workflowdataclient/workflowdataclient.go @@ -1,15 +1,18 @@ -package list +package workflowdataclient import ( "context" "fmt" "github.com/machinebox/graphql" + "github.com/rs/zerolog" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" ) const DefaultPageSize = 100 -// Workflow is a workflow row from the platform list API, decoupled from transport JSON. +// Workflow is a workflow row returned by the platform list API. type Workflow struct { Name string WorkflowID string @@ -18,6 +21,17 @@ type Workflow struct { WorkflowSource string } +// Client fetches workflow data from the CRE platform GraphQL API. +type Client struct { + graphql *graphqlclient.Client + log *zerolog.Logger +} + +// New creates a WorkflowDataClient backed by the provided GraphQL client. +func New(gql *graphqlclient.Client, log *zerolog.Logger) *Client { + return &Client{graphql: gql, log: log} +} + const listWorkflowsQuery = ` query ListWorkflows($input: WorkflowsInput!) { workflows(input: $input) { @@ -33,11 +47,6 @@ query ListWorkflows($input: WorkflowsInput!) { } ` -// Executor runs a GraphQL request (e.g. graphqlclient.Client). -type Executor interface { - Execute(ctx context.Context, req *graphql.Request, resp any) error -} - type gqlWorkflow struct { Name string `json:"name"` WorkflowID string `json:"workflowId"` @@ -53,8 +62,8 @@ type listWorkflowsEnvelope struct { } `json:"workflows"` } -// ListAll pages through ListWorkflows and returns the aggregated workflows. -func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, error) { +// ListAll pages through the ListWorkflows query and returns all workflows. +func (c *Client) ListAll(ctx context.Context, pageSize int) ([]Workflow, error) { if pageSize <= 0 { pageSize = DefaultPageSize } @@ -72,7 +81,7 @@ func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, erro }) var env listWorkflowsEnvelope - if err := exec.Execute(ctx, req, &env); err != nil { + if err := c.graphql.Execute(ctx, req, &env); err != nil { return nil, fmt.Errorf("list workflows: %w", err) } @@ -82,13 +91,7 @@ func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, erro batch := env.Workflows.Data for _, g := range batch { - all = append(all, Workflow{ - Name: g.Name, - WorkflowID: g.WorkflowID, - OwnerAddress: g.OwnerAddress, - Status: g.Status, - WorkflowSource: g.WorkflowSource, - }) + all = append(all, Workflow(g)) } if len(all) >= total || len(batch) == 0 { @@ -96,5 +99,6 @@ func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, erro } } + c.log.Debug().Int("count", len(all)).Msg("Listed workflows from platform") return all, nil } diff --git a/internal/client/workflowdataclient/workflowdataclient_test.go b/internal/client/workflowdataclient/workflowdataclient_test.go new file mode 100644 index 00000000..503c244f --- /dev/null +++ b/internal/client/workflowdataclient/workflowdataclient_test.go @@ -0,0 +1,149 @@ +package workflowdataclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +func newTestClient(t *testing.T, serverURL string) *Client { + t.Helper() + logger := testutil.NewTestLogger() + creds := &credentials.Credentials{ + AuthType: credentials.AuthTypeApiKey, + APIKey: "test-api-key", + } + envSet := &environments.EnvironmentSet{GraphQLURL: serverURL} + gql := graphqlclient.New(creds, envSet, logger) + return New(gql, logger) +} + +func TestListAll_SinglePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": 2, + "data": []map[string]string{ + { + "name": "alpha", + "workflowId": "1010101010101010101010101010101010101010101010101010101010101010", + "ownerAddress": "2020202020202020202020202020202020202020", + "status": "ACTIVE", + "workflowSource": "private", + }, + { + "name": "beta", + "workflowId": "3030303030303030303030303030303030303030303030303030303030303030", + "ownerAddress": "4040404040404040404040404040404040404040", + "status": "PAUSED", + "workflowSource": "contract:999888777666555444333:0xabababababababababababababababababababab", + }, + }, + }, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListAll(context.Background(), DefaultPageSize) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, "alpha", got[0].Name) + assert.Equal(t, "ACTIVE", got[0].Status) + assert.Equal(t, "private", got[0].WorkflowSource) + assert.Equal(t, "beta", got[1].Name) + assert.Equal(t, "PAUSED", got[1].Status) +} + +func TestListAll_Pagination(t *testing.T) { + var callCount atomic.Int32 + + page1Data := make([]map[string]string, DefaultPageSize) + for i := range page1Data { + page1Data[i] = map[string]string{ + "name": "wf-page-1", + "workflowId": "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0", + "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "status": "ACTIVE", + "workflowSource": "private", + } + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := int(callCount.Add(1)) + w.Header().Set("Content-Type", "application/json") + + switch call { + case 1: + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": DefaultPageSize + 1, + "data": page1Data, + }, + }, + }) + case 2: + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{ + "count": DefaultPageSize + 1, + "data": []map[string]string{ + { + "name": "wf-last", + "workflowId": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "status": "ACTIVE", + "workflowSource": "private", + }, + }, + }, + }, + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "workflows": map[string]any{"count": DefaultPageSize + 1, "data": []any{}}, + }, + }) + } + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + got, err := client.ListAll(context.Background(), DefaultPageSize) + require.NoError(t, err) + assert.Len(t, got, DefaultPageSize+1) + assert.Equal(t, "wf-last", got[len(got)-1].Name) + assert.Equal(t, int32(2), callCount.Load(), "expected exactly 2 HTTP calls for 2 pages") +} + +func TestListAll_GQLError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "unauthorized"}}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.ListAll(context.Background(), DefaultPageSize) + require.Error(t, err) + assert.Contains(t, err.Error(), "list workflows") + assert.Contains(t, err.Error(), "unauthorized") +}