Skip to content

Commit f7a55d8

Browse files
feat: add suite_status filter for cloud drift tests (#210)
1 parent d74dc1c commit f7a55d8

12 files changed

Lines changed: 223 additions & 19 deletions

File tree

cmd/list.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/Use-Tusk/tusk-cli/internal/runner"
1616
"github.com/Use-Tusk/tusk-cli/internal/tui"
1717
"github.com/Use-Tusk/tusk-cli/internal/utils"
18+
backend "github.com/Use-Tusk/tusk-drift-schemas/generated/go/backend"
1819
)
1920

2021
//go:embed short_docs/drift/drift_list.md
@@ -93,14 +94,26 @@ func listTests(cmd *cobra.Command, args []string) error {
9394
return formatApiError(err)
9495
}
9596

96-
all, err := api.FetchAllTraceTestsWithCache(
97-
context.Background(),
98-
client,
99-
authOptions,
100-
cfg.Service.ID,
101-
false,
102-
false,
103-
)
97+
var all []*backend.TraceTest
98+
usedStatusFilter := false
99+
if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
100+
if statusFilter := runner.ParseTraceTestStatusFilter(val); statusFilter != nil {
101+
all, err = api.FetchAllTraceTests(context.Background(), client, authOptions, cfg.Service.ID, &api.FetchAllTraceTestsOptions{
102+
StatusFilter: statusFilter,
103+
})
104+
usedStatusFilter = true
105+
}
106+
}
107+
if !usedStatusFilter && err == nil {
108+
all, err = api.FetchAllTraceTestsWithCache(
109+
context.Background(),
110+
client,
111+
authOptions,
112+
cfg.Service.ID,
113+
false,
114+
false,
115+
)
116+
}
104117
if err != nil {
105118
return formatApiError(err)
106119
}

cmd/run.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,11 @@ func runTests(cmd *cobra.Command, args []string) error {
598598
if isValidation {
599599
preloadedTests, err = fetchValidationTraceTests(context.Background(), client, authOptions, cfg.Service.ID)
600600
} else {
601-
preloadedTests, err = loadCloudTests(context.Background(), client, authOptions, cfg.Service.ID, driftRunID, traceTestID, allCloudTraceTests || !ci, quiet)
601+
var suiteStatusFilter *backend.TraceTestStatus
602+
if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
603+
suiteStatusFilter = runner.ParseTraceTestStatusFilter(val)
604+
}
605+
preloadedTests, err = loadCloudTests(context.Background(), client, authOptions, cfg.Service.ID, driftRunID, traceTestID, allCloudTraceTests || !ci, quiet, suiteStatusFilter)
602606
}
603607
if err != nil {
604608
return formatApiError(fmt.Errorf("failed to load cloud tests: %w", err))
@@ -896,7 +900,7 @@ func runTests(cmd *cobra.Command, args []string) error {
896900
return nil
897901
}
898902

899-
func loadCloudTests(ctx context.Context, client *api.TuskClient, auth api.AuthOptions, serviceID, driftRunID, traceTestID string, allCloud bool, quiet bool) ([]runner.Test, error) {
903+
func loadCloudTests(ctx context.Context, client *api.TuskClient, auth api.AuthOptions, serviceID, driftRunID, traceTestID string, allCloud bool, quiet bool, suiteStatusFilter *backend.TraceTestStatus) ([]runner.Test, error) {
900904
if traceTestID != "" {
901905
req := &backend.GetTraceTestRequest{
902906
ObservableServiceId: serviceID,
@@ -912,7 +916,14 @@ func loadCloudTests(ctx context.Context, client *api.TuskClient, auth api.AuthOp
912916
var all []*backend.TraceTest
913917
var err error
914918

915-
if allCloud {
919+
switch {
920+
case suiteStatusFilter != nil:
921+
// When filtering by suite status, bypass cache and use GetAllTraceTests
922+
// with the status filter directly
923+
all, err = api.FetchAllTraceTests(ctx, client, auth, serviceID, &api.FetchAllTraceTestsOptions{
924+
StatusFilter: suiteStatusFilter,
925+
})
926+
case allCloud:
916927
all, err = api.FetchAllTraceTestsWithCache(
917928
ctx,
918929
client,
@@ -921,7 +932,7 @@ func loadCloudTests(ctx context.Context, client *api.TuskClient, auth api.AuthOp
921932
false,
922933
quiet,
923934
)
924-
} else {
935+
default:
925936
all, err = api.FetchDriftRunTraceTests(
926937
ctx,
927938
client,
@@ -958,7 +969,11 @@ func makeLoadTestsFunc(
958969
if traceID != "" && traceTestID == "" {
959970
return nil, fmt.Errorf("specify --trace-test-id to run against a single trace test in Tusk Drift Cloud")
960971
}
961-
tests, err = loadCloudTests(ctx, client, auth, serviceID, driftRunID, traceTestID, allCloud, quiet)
972+
var suiteStatusFilter *backend.TraceTestStatus
973+
if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
974+
suiteStatusFilter = runner.ParseTraceTestStatusFilter(val)
975+
}
976+
tests, err = loadCloudTests(ctx, client, auth, serviceID, driftRunID, traceTestID, allCloud, quiet, suiteStatusFilter)
962977
if err != nil {
963978
return nil, err
964979
}

cmd/short_docs/drift/drift_filter.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22

33
Filter tests with `-f`/`--filter`.
44

5-
Fields: `path=...,name=...,type=...,method=...,status=...,id=...`.
5+
Fields: `path=...,name=...,type=...,method=...,status=...,id=...,suite_status=...`.
66
Comma-separated, values are regex.
77

8+
Use `suite_status` to filter cloud tests by suite status (`draft` or `in_suite`).
9+
When `suite_status=draft` is set, draft tests are fetched directly from the backend.
10+
811
Examples:
912

1013
```bash
1114
tusk drift <list/run> -f 'type=GRAPHQL,op=^GetUser$'
1215
tusk drift <list/run> -f 'method=POST,path=/checkout'
1316
tusk drift <list/run> -f 'file=2025-09-24.*trace.*\\.jsonl'
17+
tusk drift run --cloud -f 'suite_status=draft'
1418
```
1519

1620
See <https://github.com/Use-Tusk/tusk-cli/blob/main/docs/drift/filter.md> for more details.

docs/drift/filter.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Keys (case-insensitive; aliases in parentheses):
1616
- `status` (`s`) – test status label for display (e.g., `success`, `error`)
1717
- `id` (`trace`, `trace_id`) – trace ID
1818
- `file` (`filename`, `f`) – source file name
19+
- `suite_status` (`suite`) – cloud suite status: `draft` or `in_suite` (exact values only, not regex)
1920

2021
Notes:
2122

@@ -38,6 +39,11 @@ HTTP:
3839
- By method + route: `tusk drift run -f 'method=POST,path=/checkout'`
3940
- By type: `tusk drift list -f 'type=HTTP'`
4041

42+
Suite status (cloud only):
43+
44+
- Draft tests only: `tusk drift run --cloud -f 'suite_status=draft'`
45+
- In-suite tests only: `tusk drift run --cloud -f 'suite_status=in_suite'`
46+
4147
Trace/file:
4248

4349
- Specific trace: `tusk drift run -f 'id=84d0de6b4e4498e996c7f8b8c0f35230'`

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/Use-Tusk/fence v0.1.36
7-
github.com/Use-Tusk/tusk-drift-schemas v0.1.30
7+
github.com/Use-Tusk/tusk-drift-schemas v0.1.32
88
github.com/agnivade/levenshtein v1.0.3
99
github.com/aymanbagabas/go-osc52/v2 v2.0.1
1010
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ github.com/Use-Tusk/fence v0.1.36 h1:8S15y8cp3X+xXukx6AN0Ky/aX9/dZyW3fLw5XOQ8YtE
44
github.com/Use-Tusk/fence v0.1.36/go.mod h1:YkowBDzXioVKJE16vg9z3gSVC6vhzkIZZw2dFf7MW/o=
55
github.com/Use-Tusk/tusk-drift-schemas v0.1.30 h1:A45pJ/Za6BLIfTLF53BhuzKHHSJ9L7dXEisnuKT5dTc=
66
github.com/Use-Tusk/tusk-drift-schemas v0.1.30/go.mod h1:pa3EvTj9kKxl9f904RVFkj9YK1zB75QogboKi70zalM=
7+
github.com/Use-Tusk/tusk-drift-schemas v0.1.32 h1:9+q1RH0036rG3RDjVEeUf0ejMsVP7AxqJ8uQ+XPPCH8=
8+
github.com/Use-Tusk/tusk-drift-schemas v0.1.32/go.mod h1:pa3EvTj9kKxl9f904RVFkj9YK1zB75QogboKi70zalM=
79
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
810
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
911
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=

internal/api/fetch_tests.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ type FetchAllTraceTestsOptions struct {
3131
Message string
3232
// PageSize for pagination (default 25)
3333
PageSize int32
34+
// StatusFilter filters by trace test suite status (e.g., DRAFT, IN_SUITE).
35+
// If nil, the server defaults to IN_SUITE.
36+
StatusFilter *backend.TraceTestStatus
3437
}
3538

3639
// FetchAllTraceTests fetches all trace tests from the cloud with a progress bar.
@@ -64,6 +67,7 @@ func FetchAllTraceTests(
6467
req := &backend.GetAllTraceTestsRequest{
6568
ObservableServiceId: serviceID,
6669
PageSize: opts.PageSize,
70+
StatusFilter: opts.StatusFilter,
6771
}
6872
if cursor != "" {
6973
req.PaginationCursor = &cursor

internal/runner/convert.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func ConvertTraceTestToRunnerTest(tt *backend.TraceTest) Test {
3131
DisplayType: "HTTP", // will be overridden if we detect GraphQL/etc.
3232
DisplayName: fmt.Sprintf("Trace %s", tt.TraceId),
3333
Status: "pending",
34+
SuiteStatus: protoTraceTestStatusToString(tt.GetStatus()),
3435
}
3536

3637
// Extract environment from any span that has it
@@ -287,6 +288,34 @@ func ConvertRunnerResultToTraceTestResult(result TestResult, test Test) *backend
287288
return out
288289
}
289290

291+
func protoTraceTestStatusToString(s backend.TraceTestStatus) string {
292+
switch s {
293+
case backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT:
294+
return "draft"
295+
case backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE:
296+
return "in_suite"
297+
case backend.TraceTestStatus_TRACE_TEST_STATUS_REMOVED:
298+
return "removed"
299+
default:
300+
return ""
301+
}
302+
}
303+
304+
// ParseTraceTestStatusFilter converts a user-provided filter value to a proto TraceTestStatus.
305+
// Returns nil if the value doesn't match a known status.
306+
func ParseTraceTestStatusFilter(val string) *backend.TraceTestStatus {
307+
switch strings.ToLower(val) {
308+
case "draft":
309+
s := backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT
310+
return &s
311+
case "in_suite":
312+
s := backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE
313+
return &s
314+
default:
315+
return nil
316+
}
317+
}
318+
290319
func getStringFromStruct(s *structpb.Struct, key string) (string, bool) {
291320
if s == nil || s.Fields == nil {
292321
return "", false

internal/runner/convert_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
backend "github.com/Use-Tusk/tusk-drift-schemas/generated/go/backend"
99
core "github.com/Use-Tusk/tusk-drift-schemas/generated/go/core"
10+
"github.com/stretchr/testify/assert"
1011
"github.com/stretchr/testify/require"
1112
"google.golang.org/protobuf/types/known/durationpb"
1213
"google.golang.org/protobuf/types/known/timestamppb"
@@ -174,6 +175,57 @@ func TestConvertTraceTestsToRunnerTests(t *testing.T) {
174175
require.Equal(t, ConvertTraceTestToRunnerTest(tt2), got[1])
175176
}
176177

178+
func TestProtoTraceTestStatusToString(t *testing.T) {
179+
t.Parallel()
180+
181+
assert.Equal(t, "draft", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT))
182+
assert.Equal(t, "in_suite", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE))
183+
assert.Equal(t, "removed", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_REMOVED))
184+
assert.Equal(t, "", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_UNSPECIFIED))
185+
}
186+
187+
func TestParseTraceTestStatusFilter(t *testing.T) {
188+
t.Parallel()
189+
190+
draft := ParseTraceTestStatusFilter("draft")
191+
require.NotNil(t, draft)
192+
require.Equal(t, backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT, *draft)
193+
194+
inSuite := ParseTraceTestStatusFilter("in_suite")
195+
require.NotNil(t, inSuite)
196+
require.Equal(t, backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE, *inSuite)
197+
198+
// Case insensitive
199+
draftUpper := ParseTraceTestStatusFilter("DRAFT")
200+
require.NotNil(t, draftUpper)
201+
require.Equal(t, backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT, *draftUpper)
202+
203+
// Unknown returns nil
204+
require.Nil(t, ParseTraceTestStatusFilter("removed"))
205+
require.Nil(t, ParseTraceTestStatusFilter("unknown"))
206+
require.Nil(t, ParseTraceTestStatusFilter(""))
207+
}
208+
209+
func TestConvertTraceTestToRunnerTest_SuiteStatus(t *testing.T) {
210+
t.Parallel()
211+
212+
tt := &backend.TraceTest{
213+
Id: "tt-1",
214+
TraceId: "trace-1",
215+
Status: backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT,
216+
}
217+
got := ConvertTraceTestToRunnerTest(tt)
218+
require.Equal(t, "draft", got.SuiteStatus)
219+
220+
tt.Status = backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE
221+
got = ConvertTraceTestToRunnerTest(tt)
222+
require.Equal(t, "in_suite", got.SuiteStatus)
223+
224+
tt.Status = backend.TraceTestStatus_TRACE_TEST_STATUS_UNSPECIFIED
225+
got = ConvertTraceTestToRunnerTest(tt)
226+
require.Equal(t, "", got.SuiteStatus)
227+
}
228+
177229
func TestConvertRunnerResultToTraceTestResult(t *testing.T) {
178230
t.Parallel()
179231

internal/runner/filter.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,18 @@ func parseFieldedFilter(q string) ([]fieldMatcher, error) {
6161
if len(val) >= 2 && ((val[0] == '\'' && val[len(val)-1] == '\'') || (val[0] == '"' && val[len(val)-1] == '"')) {
6262
val = val[1 : len(val)-1]
6363
}
64-
re, err := regexp.Compile(val)
65-
if err != nil {
66-
return nil, fmt.Errorf("invalid regex for %s: %w", key, err)
67-
}
6864
field := normalizeFilterFieldKey(key)
6965
if field == "" {
7066
return nil, fmt.Errorf("unknown filter field: %s", key)
7167
}
68+
// suite_status values are always lowercase; make matching case-insensitive
69+
if field == "suite_status" {
70+
val = "(?i)" + val
71+
}
72+
re, err := regexp.Compile(val)
73+
if err != nil {
74+
return nil, fmt.Errorf("invalid regex for %s: %w", key, err)
75+
}
7276
out = append(out, fieldMatcher{field: field, re: re})
7377
}
7478
return out, nil
@@ -123,6 +127,8 @@ func normalizeFilterFieldKey(k string) string {
123127
return "id"
124128
case "file", "filename", "f":
125129
return "file"
130+
case "suite_status", "suite":
131+
return "suite_status"
126132
default:
127133
return ""
128134
}
@@ -155,6 +161,8 @@ func getFieldValueForFilter(t Test, field string) string {
155161
return t.TraceID
156162
case "file":
157163
return t.FileName
164+
case "suite_status":
165+
return t.SuiteStatus
158166
default:
159167
return ""
160168
}
@@ -172,6 +180,23 @@ func extractGraphQLOperationName(displayName string) string {
172180
return displayName
173181
}
174182

183+
// ExtractSuiteStatusFromFilter extracts the suite_status value from a filter string.
184+
// Returns the value and true if found, empty string and false otherwise.
185+
func ExtractSuiteStatusFromFilter(filter string) (string, bool) {
186+
matchers, err := parseFieldedFilter(filter)
187+
if err != nil {
188+
return "", false
189+
}
190+
for _, m := range matchers {
191+
if m.field == "suite_status" {
192+
val := m.re.String()
193+
val = strings.TrimPrefix(val, "(?i)")
194+
return val, true
195+
}
196+
}
197+
return "", false
198+
}
199+
175200
// FilterLocalTestsForExecution filters out local tests with HTTP status >= 300.
176201
// These tests are skipped for replay but their spans remain available for mock matching.
177202
// Returns (testsToExecute, excludedCount).

0 commit comments

Comments
 (0)