Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/Use-Tusk/tusk-cli/internal/runner"
"github.com/Use-Tusk/tusk-cli/internal/tui"
"github.com/Use-Tusk/tusk-cli/internal/utils"
backend "github.com/Use-Tusk/tusk-drift-schemas/generated/go/backend"
)

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

all, err := api.FetchAllTraceTestsWithCache(
context.Background(),
client,
authOptions,
cfg.Service.ID,
false,
false,
)
var all []*backend.TraceTest
usedStatusFilter := false
if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: suite_status regex values silently fall back to the cache path, which only returns IN_SUITE tests, so valid filter forms (for example suite_status=^draft$) can miss draft tests.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/list.go, line 99:

<comment>`suite_status` regex values silently fall back to the cache path, which only returns IN_SUITE tests, so valid filter forms (for example `suite_status=^draft$`) can miss draft tests.</comment>

<file context>
@@ -93,14 +94,26 @@ func listTests(cmd *cobra.Command, args []string) error {
-		)
+		var all []*backend.TraceTest
+		usedStatusFilter := false
+		if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
+			if statusFilter := runner.ParseTraceTestStatusFilter(val); statusFilter != nil {
+				all, err = api.FetchAllTraceTests(context.Background(), client, authOptions, cfg.Service.ID, &api.FetchAllTraceTestsOptions{
</file context>
Fix with Cubic

if statusFilter := runner.ParseTraceTestStatusFilter(val); statusFilter != nil {
all, err = api.FetchAllTraceTests(context.Background(), client, authOptions, cfg.Service.ID, &api.FetchAllTraceTestsOptions{
StatusFilter: statusFilter,
})
usedStatusFilter = true
}
}
if !usedStatusFilter && err == nil {
all, err = api.FetchAllTraceTestsWithCache(
context.Background(),
client,
authOptions,
cfg.Service.ID,
false,
false,
)
}
if err != nil {
return formatApiError(err)
}
Expand Down
22 changes: 18 additions & 4 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,11 @@
if isValidation {
preloadedTests, err = fetchValidationTraceTests(context.Background(), client, authOptions, cfg.Service.ID)
} else {
preloadedTests, err = loadCloudTests(context.Background(), client, authOptions, cfg.Service.ID, driftRunID, traceTestID, allCloudTraceTests || !ci, quiet)
var suiteStatusFilter *backend.TraceTestStatus
if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
suiteStatusFilter = runner.ParseTraceTestStatusFilter(val)
}
preloadedTests, err = loadCloudTests(context.Background(), client, authOptions, cfg.Service.ID, driftRunID, traceTestID, allCloudTraceTests || !ci, quiet, suiteStatusFilter)
}
if err != nil {
return formatApiError(fmt.Errorf("failed to load cloud tests: %w", err))
Expand Down Expand Up @@ -896,7 +900,7 @@
return nil
}

func loadCloudTests(ctx context.Context, client *api.TuskClient, auth api.AuthOptions, serviceID, driftRunID, traceTestID string, allCloud bool, quiet bool) ([]runner.Test, error) {
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) {
if traceTestID != "" {
req := &backend.GetTraceTestRequest{
ObservableServiceId: serviceID,
Expand All @@ -912,7 +916,13 @@
var all []*backend.TraceTest
var err error

if allCloud {
if suiteStatusFilter != nil {

Check failure on line 919 in cmd/run.go

View workflow job for this annotation

GitHub Actions / Lint

ifElseChain: rewrite if-else to switch statement (gocritic)
// When filtering by suite status, bypass cache and use GetAllTraceTests
// with the status filter directly
all, err = api.FetchAllTraceTests(ctx, client, auth, serviceID, &api.FetchAllTraceTestsOptions{
StatusFilter: suiteStatusFilter,
})
} else if allCloud {
all, err = api.FetchAllTraceTestsWithCache(
ctx,
client,
Expand Down Expand Up @@ -958,7 +968,11 @@
if traceID != "" && traceTestID == "" {
return nil, fmt.Errorf("specify --trace-test-id to run against a single trace test in Tusk Drift Cloud")
}
tests, err = loadCloudTests(ctx, client, auth, serviceID, driftRunID, traceTestID, allCloud, quiet)
var suiteStatusFilter *backend.TraceTestStatus
if val, ok := runner.ExtractSuiteStatusFromFilter(filter); ok {
suiteStatusFilter = runner.ParseTraceTestStatusFilter(val)
}
tests, err = loadCloudTests(ctx, client, auth, serviceID, driftRunID, traceTestID, allCloud, quiet, suiteStatusFilter)
if err != nil {
return nil, err
}
Expand Down
6 changes: 5 additions & 1 deletion cmd/short_docs/drift/drift_filter.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

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

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

Use `suite_status` to filter cloud tests by suite status (`draft` or `in_suite`).
When `suite_status=draft` is set, draft tests are fetched directly from the backend.

Examples:

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

See <https://github.com/Use-Tusk/tusk-cli/blob/main/docs/drift/filter.md> for more details.
6 changes: 6 additions & 0 deletions docs/drift/filter.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Keys (case-insensitive; aliases in parentheses):
- `status` (`s`) – test status label for display (e.g., `success`, `error`)
- `id` (`trace`, `trace_id`) – trace ID
- `file` (`filename`, `f`) – source file name
- `suite_status` (`suite`) – cloud suite status: `draft` or `in_suite` (exact values only, not regex)

Notes:

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

Suite status (cloud only):

- Draft tests only: `tusk drift run --cloud -f 'suite_status=draft'`
- In-suite tests only: `tusk drift run --cloud -f 'suite_status=in_suite'`

Trace/file:

- Specific trace: `tusk drift run -f 'id=84d0de6b4e4498e996c7f8b8c0f35230'`
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.0

require (
github.com/Use-Tusk/fence v0.1.36
github.com/Use-Tusk/tusk-drift-schemas v0.1.30
github.com/Use-Tusk/tusk-drift-schemas v0.1.32
github.com/agnivade/levenshtein v1.0.3
github.com/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/Use-Tusk/fence v0.1.36 h1:8S15y8cp3X+xXukx6AN0Ky/aX9/dZyW3fLw5XOQ8YtE
github.com/Use-Tusk/fence v0.1.36/go.mod h1:YkowBDzXioVKJE16vg9z3gSVC6vhzkIZZw2dFf7MW/o=
github.com/Use-Tusk/tusk-drift-schemas v0.1.30 h1:A45pJ/Za6BLIfTLF53BhuzKHHSJ9L7dXEisnuKT5dTc=
github.com/Use-Tusk/tusk-drift-schemas v0.1.30/go.mod h1:pa3EvTj9kKxl9f904RVFkj9YK1zB75QogboKi70zalM=
github.com/Use-Tusk/tusk-drift-schemas v0.1.32 h1:9+q1RH0036rG3RDjVEeUf0ejMsVP7AxqJ8uQ+XPPCH8=
github.com/Use-Tusk/tusk-drift-schemas v0.1.32/go.mod h1:pa3EvTj9kKxl9f904RVFkj9YK1zB75QogboKi70zalM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
Expand Down
4 changes: 4 additions & 0 deletions internal/api/fetch_tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type FetchAllTraceTestsOptions struct {
Message string
// PageSize for pagination (default 25)
PageSize int32
// StatusFilter filters by trace test suite status (e.g., DRAFT, IN_SUITE).
// If nil, the server defaults to IN_SUITE.
StatusFilter *backend.TraceTestStatus
}

// FetchAllTraceTests fetches all trace tests from the cloud with a progress bar.
Expand Down Expand Up @@ -64,6 +67,7 @@ func FetchAllTraceTests(
req := &backend.GetAllTraceTestsRequest{
ObservableServiceId: serviceID,
PageSize: opts.PageSize,
StatusFilter: opts.StatusFilter,
}
if cursor != "" {
req.PaginationCursor = &cursor
Expand Down
29 changes: 29 additions & 0 deletions internal/runner/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func ConvertTraceTestToRunnerTest(tt *backend.TraceTest) Test {
DisplayType: "HTTP", // will be overridden if we detect GraphQL/etc.
DisplayName: fmt.Sprintf("Trace %s", tt.TraceId),
Status: "pending",
SuiteStatus: protoTraceTestStatusToString(tt.GetStatus()),
}

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

func protoTraceTestStatusToString(s backend.TraceTestStatus) string {
switch s {
case backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT:
return "draft"
case backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE:
return "in_suite"
case backend.TraceTestStatus_TRACE_TEST_STATUS_REMOVED:
return "removed"
default:
return ""
}
}

// ParseTraceTestStatusFilter converts a user-provided filter value to a proto TraceTestStatus.
// Returns nil if the value doesn't match a known status.
func ParseTraceTestStatusFilter(val string) *backend.TraceTestStatus {
switch strings.ToLower(val) {
case "draft":
s := backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT
return &s
case "in_suite":
s := backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE
return &s
default:
return nil
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case-insensitive server filter vs case-sensitive client regex

Medium Severity

ParseTraceTestStatusFilter uses strings.ToLower to accept case-insensitive input (e.g., "DRAFT"), correctly fetching draft tests from the server. However, the same original-case value is also used as a regex in the client-side FilterTests call, where it's matched against the lowercase SuiteStatus field ("draft" from protoTraceTestStatusToString). Since Go regex is case-sensitive, suite_status=DRAFT correctly fetches draft tests from the backend but then filters them all out on the client side, returning an empty result.

Additional Locations (2)
Fix in Cursor Fix in Web


func getStringFromStruct(s *structpb.Struct, key string) (string, bool) {
if s == nil || s.Fields == nil {
return "", false
Expand Down
52 changes: 52 additions & 0 deletions internal/runner/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

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

func TestProtoTraceTestStatusToString(t *testing.T) {
t.Parallel()

assert.Equal(t, "draft", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT))
assert.Equal(t, "in_suite", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE))
assert.Equal(t, "removed", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_REMOVED))
assert.Equal(t, "", protoTraceTestStatusToString(backend.TraceTestStatus_TRACE_TEST_STATUS_UNSPECIFIED))
}

func TestParseTraceTestStatusFilter(t *testing.T) {
t.Parallel()

draft := ParseTraceTestStatusFilter("draft")
require.NotNil(t, draft)
require.Equal(t, backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT, *draft)

inSuite := ParseTraceTestStatusFilter("in_suite")
require.NotNil(t, inSuite)
require.Equal(t, backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE, *inSuite)

// Case insensitive
draftUpper := ParseTraceTestStatusFilter("DRAFT")
require.NotNil(t, draftUpper)
require.Equal(t, backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT, *draftUpper)

// Unknown returns nil
require.Nil(t, ParseTraceTestStatusFilter("removed"))
require.Nil(t, ParseTraceTestStatusFilter("unknown"))
require.Nil(t, ParseTraceTestStatusFilter(""))
}

func TestConvertTraceTestToRunnerTest_SuiteStatus(t *testing.T) {
t.Parallel()

tt := &backend.TraceTest{
Id: "tt-1",
TraceId: "trace-1",
Status: backend.TraceTestStatus_TRACE_TEST_STATUS_DRAFT,
}
got := ConvertTraceTestToRunnerTest(tt)
require.Equal(t, "draft", got.SuiteStatus)

tt.Status = backend.TraceTestStatus_TRACE_TEST_STATUS_IN_SUITE
got = ConvertTraceTestToRunnerTest(tt)
require.Equal(t, "in_suite", got.SuiteStatus)

tt.Status = backend.TraceTestStatus_TRACE_TEST_STATUS_UNSPECIFIED
got = ConvertTraceTestToRunnerTest(tt)
require.Equal(t, "", got.SuiteStatus)
}

func TestConvertRunnerResultToTraceTestResult(t *testing.T) {
t.Parallel()

Expand Down
19 changes: 19 additions & 0 deletions internal/runner/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ func normalizeFilterFieldKey(k string) string {
return "id"
case "file", "filename", "f":
return "file"
case "suite_status", "suite":
return "suite_status"
default:
return ""
}
Expand Down Expand Up @@ -155,6 +157,8 @@ func getFieldValueForFilter(t Test, field string) string {
return t.TraceID
case "file":
return t.FileName
case "suite_status":
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: ParseTraceTestStatusFilter normalizes to lowercase via strings.ToLower for the backend fetch, but the same user-provided value (e.g., DRAFT) is kept as-is in the filter string passed to FilterTests. Since SuiteStatus on the Test struct is always lowercase ("draft", "in_suite" from protoTraceTestStatusToString), and Go regex is case-sensitive, a filter like suite_status=DRAFT will correctly fetch draft tests from the server but then filter them all out on the client side, returning zero results.

Either normalize the suite_status filter value to lowercase before client-side filtering, or use a case-insensitive regex (e.g., (?i) prefix) for the suite_status field.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/runner/filter.go, line 160:

<comment>`ParseTraceTestStatusFilter` normalizes to lowercase via `strings.ToLower` for the backend fetch, but the same user-provided value (e.g., `DRAFT`) is kept as-is in the filter string passed to `FilterTests`. Since `SuiteStatus` on the `Test` struct is always lowercase (`"draft"`, `"in_suite"` from `protoTraceTestStatusToString`), and Go regex is case-sensitive, a filter like `suite_status=DRAFT` will correctly fetch draft tests from the server but then filter them all out on the client side, returning zero results.

Either normalize the `suite_status` filter value to lowercase before client-side filtering, or use a case-insensitive regex (e.g., `(?i)` prefix) for the `suite_status` field.</comment>

<file context>
@@ -155,6 +157,8 @@ func getFieldValueForFilter(t Test, field string) string {
 		return t.TraceID
 	case "file":
 		return t.FileName
+	case "suite_status":
+		return t.SuiteStatus
 	default:
</file context>
Fix with Cubic

return t.SuiteStatus
default:
return ""
}
Expand All @@ -172,6 +176,21 @@ func extractGraphQLOperationName(displayName string) string {
return displayName
}

// ExtractSuiteStatusFromFilter extracts the suite_status value from a filter string.
// Returns the value and true if found, empty string and false otherwise.
func ExtractSuiteStatusFromFilter(filter string) (string, bool) {
matchers, err := parseFieldedFilter(filter)
if err != nil {
return "", false
}
for _, m := range matchers {
if m.field == "suite_status" {
return m.re.String(), true
}
}
return "", false
}

// FilterLocalTestsForExecution filters out local tests with HTTP status >= 300.
// These tests are skipped for replay but their spans remain available for mock matching.
// Returns (testsToExecute, excludedCount).
Expand Down
48 changes: 48 additions & 0 deletions internal/runner/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func TestGetFieldValueForFilter(t *testing.T) {
Status: "PASSED",
TraceID: "trace-1",
FileName: "users.graphql",
SuiteStatus: "draft",
}

assert.Equal(t, "/graphql/users", getFieldValueForFilter(graphQLTest, "path"))
Expand All @@ -130,11 +131,58 @@ func TestGetFieldValueForFilter(t *testing.T) {
assert.Equal(t, "PASSED", getFieldValueForFilter(graphQLTest, "status"))
assert.Equal(t, "trace-1", getFieldValueForFilter(graphQLTest, "id"))
assert.Equal(t, "users.graphql", getFieldValueForFilter(graphQLTest, "file"))
assert.Equal(t, "draft", getFieldValueForFilter(graphQLTest, "suite_status"))

fallbackType := Test{Type: "REST"}
assert.Equal(t, "REST", getFieldValueForFilter(fallbackType, "type"))

assert.Equal(t, "", getFieldValueForFilter(Test{}, "unknown"))
assert.Equal(t, "", getFieldValueForFilter(Test{}, "suite_status"))
}

func TestFilterTestsBySuiteStatus(t *testing.T) {
tests := []Test{
{Path: "/api/users", SuiteStatus: "draft", TraceID: "t-1"},
{Path: "/api/orders", SuiteStatus: "in_suite", TraceID: "t-2"},
{Path: "/api/items", SuiteStatus: "draft", TraceID: "t-3"},
{Path: "/api/local", SuiteStatus: "", TraceID: "t-4"}, // local test, no suite status
}

filtered, err := FilterTests(tests, "suite_status=draft")
require.NoError(t, err)
require.Len(t, filtered, 2)
assert.Equal(t, "t-1", filtered[0].TraceID)
assert.Equal(t, "t-3", filtered[1].TraceID)

filtered, err = FilterTests(tests, "suite_status=in_suite")
require.NoError(t, err)
require.Len(t, filtered, 1)
assert.Equal(t, "t-2", filtered[0].TraceID)

// "suite" alias
filtered, err = FilterTests(tests, "suite=draft")
require.NoError(t, err)
require.Len(t, filtered, 2)
}

func TestExtractSuiteStatusFromFilter(t *testing.T) {
val, ok := ExtractSuiteStatusFromFilter("suite_status=draft")
assert.True(t, ok)
assert.Equal(t, "draft", val)

val, ok = ExtractSuiteStatusFromFilter("suite=in_suite")
assert.True(t, ok)
assert.Equal(t, "in_suite", val)

val, ok = ExtractSuiteStatusFromFilter("type=GRAPHQL,suite_status=draft")
assert.True(t, ok)
assert.Equal(t, "draft", val)

_, ok = ExtractSuiteStatusFromFilter("type=GRAPHQL")
assert.False(t, ok)

_, ok = ExtractSuiteStatusFromFilter("")
assert.False(t, ok)
}

func TestExtractGraphQLOperationName(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions internal/runner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Test struct {
Path string `json:"path"` // Used for test execution
DisplayName string `json:"display_name"` // Used for CLI display
Status string `json:"status"`
SuiteStatus string `json:"suite_status,omitempty"` // Cloud only: "draft", "in_suite"
Duration int `json:"duration"`
Metadata map[string]any `json:"metadata"`
Request Request `json:"request"`
Expand Down
Loading