diff --git a/docs/index.md b/docs/index.md index c2b88dcde..2434d6f38 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2590,8 +2590,8 @@ The real power, of labels, however, is around filtering. You can filter by labe - The `!` unary operator representing the NOT operation. - The `,` binary operator equivalent to `||`. - The `()` for grouping expressions. -- All other characters will match as label literals. Label matches are **case insensitive** and trailing and leading whitespace is trimmed. - Regular expressions can be provided using `/REGEXP/` notation. +- All other characters will match as label literals. Label matches are **case insensitive** and trailing and leading whitespace is trimmed. To build on our example above, here are some label filter queries and their behavior: @@ -2602,10 +2602,65 @@ To build on our example above, here are some label filter queries and their beha | `ginkgo --label-filter="network && !slow"` | Run specs labelled `network` that aren't `slow` | | `ginkgo --label-filter=/library/` | Run specs with labels matching the regular expression `library` - this will match the three library-related specs in our example. +##### Label Sets + +In addition to flat strings, Labels can also construct sets. If a label has the format `KEY:VALUE` then a set with key `KEY` is created and the value `VALUE` is added to the set. For example: + +```go +Describe("The Library API", Label("API:Library"), func() { + It("can fetch a list of books", func() { + // has the labels [API:Library] + // API is a set with value {Library} + }) + It("can fetch a list of books by shelf", Label("API:Shelf", "Readiness:Alpha"), func() { + // has the labels [API:Library, API:Shelf, Readiness:Alpha] + // API is a set with value {Library, Shelf} + // Readiness is a set with value {Alpha} + + }) + It("can fetch a list of books by zip code", Label("API:Geo", "Readiness:Beta"), func() { + // has the labels [API:Library, API:Geo, Readiness:Beta] + // API is a set with value {Library, Geo} + // Readiness is a set with value {Beta} + }) +}) +``` + +Label filters can operate on sets using the notation: `KEY: SET_OPERATION `. The following set operations are supported: + +| Set Operation | Argument | Description | +| --- | --- | --- | +| `isEmpty` | None | Matches if the set with key `KEY` is empty (i.e. no label of the form `KEY:*` exists) | +| `containsAny` | `SINGLE_VALUE` or `{VALUE1, VALUE2, ...}` | Matches if the `KEY` set contains _any_ of the elements in `ARGUMENT` | +| `containsAll` | `SINGLE_VALUE` or `{VALUE1, VALUE2, ...}` | Matches if the `KEY` set contains _all_ of the elements in `ARGUMENT` | +| `consistsOf` | `SINGLE_VALUE` or `{VALUE1, VALUE2, ...}` | Matches if the `KEY` set contains _exactly_ the elements in `ARGUMENT` | +| `isSubsetOf` | `SINGLE_VALUE` or `{VALUE1, VALUE2, ...}` | Matches if the elements in the `KEY` set are a subset of the elements in `ARGUMENT` | + +Leading and trailing whitespace is always trimmed around keys and values and comparisons are always case-insensitive. Keys and values in the filter-language set operations are always literals; regular expressions are not supported. + +A special note should be made about the behavior of `isSubsetOf`: if the `KEY` set is empty then the filter will always match. This is because an empty set is always a subset of any other set. + +You can combine set operations with other label filters using the logical operators. For example: `ginkgo --label-filter="integration && !slow && Readiness: isSubsetOf {Beta, RC}"` will run all tests that have the label `integration`, do not have the label `slow` and have a `Readiness` set that is a subset of `{Beta, RC}`. This would exclude `Readiness:Alpha` but include specs with `Readiness:Beta` and `Readiness:RC` as well as specs with no `Readiness:*` label. + +Some more examples: + +| Query | Behavior | +| --- | --- | +| `ginkgo --label-filter="API: consistsOf {Library, Geo}"` | Match any specs for which the `API` set contains exactly `Library` and `Geo` | +| `ginkgo --label-filter="API: containsAny Library"` | Match any specs for which the `API` set contains `Library` | +| `ginkgo --label-filter="Readiness: isEmpty"` | Match any specs for which the `Readiness` set is empty | +| `ginkgo --label-filter="Readiness: isSubsetOf Beta && !(API: containsAny Geo)"` | Match any specs for which the `Readiness` set is a subset of `{Beta}` (or empty) and the `API` set does not contain `Geo` | + +Label sets are helpful for organizing and filtering large spec suites in which different specs satisfy multiple overlapping concerns. The use of label set filters is intended to be a more powerful and expressive alternative to the use of regular expressions. If you find yourself using a regular expression, consider if you should be using a label set instead. + +##### Listing Labels + You can list the labels used in a given package using the `ginkgo labels` subcommand. This does a simple/naive scan of your test files for calls to `Label` and returns any labels it finds. You can iterate on different filters quickly with `ginkgo --dry-run -v --label-filter=FILTER`. This will cause Ginkgo to tell you which specs it will run for a given filter without actually running anything. +##### Runtime Label Evaluation + If you want to have finer-grained control within a test about what code to run/not-run depending on what labels match/don't match the filter you can perform a manual check against the label-filter passed into Ginkgo like so: ```go @@ -2620,6 +2675,8 @@ It("can save books remotely", Label("network", "slow", "library query") { here `GinkgoLabelFilter()` returns the configured label filter passed in via `--label-filter`. With a setup like this you could run `ginkgo --label-filter="network && !performance"` - this would select the `"can save books remotely"` spec but not run the benchmarking code in the spec. Of course, this could also have been modeled as a separate spec with the `performance` label. +##### Suite-Level Labels + Finally, in addition to specifying Labels on subject and container nodes you can also specify suite-wide labels by decorating the `RunSpecs` command with `Label`: ```go @@ -2631,7 +2688,6 @@ func TestBooks(t *testing.T) { Suite-level labels apply to the entire suite making it easy to filter out entire suites using label filters. - #### Location-Based Filtering Ginkgo allows you to filter specs based on their source code location from the command line. You do this using the `ginkgo --focus-file` and `ginkgo --skip-file` flags. Ginkgo will only run specs that are in files that _do_ match the `--focus-file` filter *and* _don't_ match the `--skip-file` filter. You can provide multiple `--focus-file` and `--skip-file` flags. The `--focus-file`s will be ORed together and the `--skip-file`s will be ORed together. diff --git a/integration/_fixtures/filter_fixture/widget_b_test.go b/integration/_fixtures/filter_fixture/widget_b_test.go index cff5c1eca..4271048e5 100644 --- a/integration/_fixtures/filter_fixture/widget_b_test.go +++ b/integration/_fixtures/filter_fixture/widget_b_test.go @@ -13,11 +13,15 @@ var _ = Describe("WidgetB", func() { }) + It("fish", Label("Feature:Alpha"), func() { + + }) + It("cat fish", func() { }) - It("dog fish", func() { + It("dog fish", Label("Feature:Beta"), func() { }) }) diff --git a/integration/filter_test.go b/integration/filter_test.go index a4f8802a2..95ef4a3a5 100644 --- a/integration/filter_test.go +++ b/integration/filter_test.go @@ -21,7 +21,7 @@ var _ = Describe("Filter", func() { "--focus-file=sprocket", "--focus-file=widget:1-24", "--focus-file=_b:24-42", "--skip-file=_c", "--json-report=report.json", - "--label-filter=TopLevelLabel && !SLOW", + "--label-filter=TopLevelLabel && !SLOW && !(Feature: containsAny Alpha)", ) Eventually(session).Should(gexec.Exit(0)) specs := Reports(fm.LoadJSONReports("filter", "report.json")[0].SpecReports) @@ -43,6 +43,8 @@ var _ = Describe("Filter", func() { "SprocketA cat", "SprocketB cat", "WidgetA cat", "WidgetB cat", "More WidgetB cat", // fish is in -focus but cat is in -skip "SprocketA cat fish", "SprocketB cat fish", "WidgetA cat fish", "WidgetB cat fish", "More WidgetB cat fish", + // Tests with Feature:Alpha + "WidgetB fish", // Tests labelled 'slow' "WidgetB dog", "SprocketB fish", @@ -95,7 +97,7 @@ var _ = Describe("Filter", func() { It("can list labels", func() { session := startGinkgo(fm.TmpDir, "labels", "-r") Eventually(session).Should(gexec.Exit(0)) - Ω(session).Should(gbytes.Say(`filter: \["TopLevelLabel", "slow"\]`)) + Ω(session).Should(gbytes.Say(`filter: \["Feature:Alpha", "Feature:Beta", "TopLevelLabel", "slow"\]`)) Ω(session).Should(gbytes.Say(`labels: \["beluga", "bird", "cat", "chicken", "cow", "dog", "giraffe", "koala", "monkey", "otter", "owl", "panda"\]`)) Ω(session).Should(gbytes.Say(`nolabels: No labels found`)) Ω(session).Should(gbytes.Say(`onepkg: \["beluga", "bird", "cat", "chicken", "cow", "dog", "giraffe", "koala", "monkey", "otter", "owl", "panda"\]`)) diff --git a/internal/focus_test.go b/internal/focus_test.go index 99f08504c..b8332669b 100644 --- a/internal/focus_test.go +++ b/internal/focus_test.go @@ -254,6 +254,27 @@ var _ = Describe("Focus", func() { }) }) + Context("when configured with a label set filter", func() { + BeforeEach(func() { + conf.LabelFilter = "Feature: consistsOf {A, B} || Feature: containsAny C" + specs = Specs{ + S(N(ntCon, Label("Feature:A", "dog")), N(ntIt, "A", Label("fish"))), //skip because fish no feature:B + S(N(ntCon, Label("Feature:A", "dog")), N(ntIt, "B", Label("apple", "Feature:B"))), //include because has Feature:A and Feature:B + S(N(ntCon, Label("Feature:A")), N(ntIt, "C", Label("Feature:B", "Feature:D"))), //skip because it has Feature:D + S(N(ntCon, Label("Feature:C")), N(ntIt, "D", Label("fish", "Feature:D"))), //include because it has Feature:C + S(N(ntCon, Label("cow")), N(ntIt, "E")), //skip because no Feature: + S(N(ntCon, Label("Feature:A", "Feature:B")), N(ntIt, "F", Pending)), //skip because pending + } + }) + + It("applies the label filters", func() { + specs, hasProgrammaticFocus := internal.ApplyFocusToSpecs(specs, description, suiteLabels, conf) + Ω(harvestSkips(specs)).Should(Equal([]bool{true, false, true, false, true, true})) + Ω(hasProgrammaticFocus).Should(BeFalse()) + + }) + }) + Context("when configured with a label filter that filters on the suite level label", func() { BeforeEach(func() { conf.LabelFilter = "cat && TopLevelLabel" diff --git a/internal/internal_integration/labels_test.go b/internal/internal_integration/labels_test.go index 09811c49e..9ed6d35be 100644 --- a/internal/internal_integration/labels_test.go +++ b/internal/internal_integration/labels_test.go @@ -27,10 +27,18 @@ var _ = Describe("Labels", func() { It("H", rt.T("H"), Label("fish", "chicken")) }) }) + Describe("feature container", Label("Feature:Beta"), func() { + It("I", rt.T("I"), Label("Feature: Gamma")) + Describe("inner container", Label(" feature : alpha "), func() { + It("J", rt.T("J"), Label("Feature:Alpha")) + It("K", rt.T("K"), Label("Feature:Delta", "Feature:Beta")) + }) + + }) }) } BeforeEach(func() { - conf.LabelFilter = "TopLevelLabel && (dog || cow)" + conf.LabelFilter = "TopLevelLabel && (dog || cow) || Feature: containsAny Alpha" success, hPF := RunFixture("labelled tests", fixture) Ω(success).Should(BeTrue()) Ω(hPF).Should(BeFalse()) @@ -68,6 +76,18 @@ var _ = Describe("Labels", func() { Ω(reporter.Did.Find("H").ContainerHierarchyLabels).Should(Equal([][]string{{}, {"giraffe"}, {"cow"}})) Ω(reporter.Did.Find("H").LeafNodeLabels).Should(Equal([]string{"fish", "chicken"})) Ω(reporter.Did.Find("H").Labels()).Should(Equal([]string{"giraffe", "cow", "fish", "chicken"})) + + Ω(reporter.Did.Find("I").ContainerHierarchyLabels).Should(Equal([][]string{{}, {"Feature:Beta"}})) + Ω(reporter.Did.Find("I").LeafNodeLabels).Should(Equal([]string{"Feature: Gamma"})) + Ω(reporter.Did.Find("I").Labels()).Should(Equal([]string{"Feature:Beta", "Feature: Gamma"})) + + Ω(reporter.Did.Find("J").ContainerHierarchyLabels).Should(Equal([][]string{{}, {"Feature:Beta"}, {"feature : alpha"}})) + Ω(reporter.Did.Find("J").LeafNodeLabels).Should(Equal([]string{"Feature:Alpha"})) + Ω(reporter.Did.Find("J").Labels()).Should(Equal([]string{"Feature:Beta", "feature : alpha", "Feature:Alpha"})) + + Ω(reporter.Did.Find("K").ContainerHierarchyLabels).Should(Equal([][]string{{}, {"Feature:Beta"}, {"feature : alpha"}})) + Ω(reporter.Did.Find("K").LeafNodeLabels).Should(Equal([]string{"Feature:Delta", "Feature:Beta"})) + Ω(reporter.Did.Find("K").Labels()).Should(Equal([]string{"Feature:Beta", "feature : alpha", "Feature:Delta"})) }) It("includes suite labels in the suite report", func() { @@ -76,11 +96,11 @@ var _ = Describe("Labels", func() { }) It("honors the LabelFilter config and skips tests appropriately", func() { - Ω(rt).Should(HaveTracked("B", "C", "D", "F", "H")) - Ω(reporter.Did.WithState(types.SpecStatePassed).Names()).Should(ConsistOf("B", "C", "D", "F", "H")) - Ω(reporter.Did.WithState(types.SpecStateSkipped).Names()).Should(ConsistOf("A", "E")) + Ω(rt).Should(HaveTracked("B", "C", "D", "F", "H", "J", "K")) + Ω(reporter.Did.WithState(types.SpecStatePassed).Names()).Should(ConsistOf("B", "C", "D", "F", "H", "J", "K")) + Ω(reporter.Did.WithState(types.SpecStateSkipped).Names()).Should(ConsistOf("A", "E", "I")) Ω(reporter.Did.WithState(types.SpecStatePending).Names()).Should(ConsistOf("G")) - Ω(reporter.End).Should(BeASuiteSummary(true, NPassed(5), NSkipped(2), NPending(1), NSpecs(8), NWillRun(5))) + Ω(reporter.End).Should(BeASuiteSummary(true, NPassed(7), NSkipped(3), NPending(1), NSpecs(11), NWillRun(7))) }) }) diff --git a/internal/node_test.go b/internal/node_test.go index 8d98e316f..138bf49fe 100644 --- a/internal/node_test.go +++ b/internal/node_test.go @@ -444,9 +444,9 @@ var _ = Describe("Constructing nodes", func() { }) It("validates labels", func() { - node, errors := internal.NewNode(dt, ntIt, "", body, cl, Label("A", "B&C", "C,D", "C,D ", " ")) + node, errors := internal.NewNode(dt, ntIt, "", body, cl, Label("A", "B&C", "C,D", "C,D ", " ", ":Foo")) Ω(node).Should(BeZero()) - Ω(errors).Should(ConsistOf(types.GinkgoErrors.InvalidLabel("B&C", cl), types.GinkgoErrors.InvalidLabel("C,D", cl), types.GinkgoErrors.InvalidLabel("C,D ", cl), types.GinkgoErrors.InvalidEmptyLabel(cl))) + Ω(errors).Should(ConsistOf(types.GinkgoErrors.InvalidLabel("B&C", cl), types.GinkgoErrors.InvalidLabel("C,D", cl), types.GinkgoErrors.InvalidLabel("C,D ", cl), types.GinkgoErrors.InvalidEmptyLabel(cl), types.GinkgoErrors.InvalidLabel(":Foo", cl))) Ω(dt.DidTrackDeprecations()).Should(BeFalse()) }) }) diff --git a/types/label_filter.go b/types/label_filter.go index b0d3b651e..7fdc8aa23 100644 --- a/types/label_filter.go +++ b/types/label_filter.go @@ -45,6 +45,83 @@ func orAction(a, b LabelFilter) LabelFilter { return func(labels []string) bool { return a(labels) || b(labels) } } +func labelSetFor(key string, labels []string) map[string]bool { + key = strings.ToLower(strings.TrimSpace(key)) + out := map[string]bool{} + for _, label := range labels { + components := strings.SplitN(label, ":", 2) + if len(components) < 2 { + continue + } + if key == strings.ToLower(strings.TrimSpace(components[0])) { + out[strings.ToLower(strings.TrimSpace(components[1]))] = true + } + } + + return out +} + +func isEmptyLabelSetAction(key string) LabelFilter { + return func(labels []string) bool { + return len(labelSetFor(key, labels)) == 0 + } +} + +func containsAnyLabelSetAction(key string, expectedValues []string) LabelFilter { + return func(labels []string) bool { + set := labelSetFor(key, labels) + for _, value := range expectedValues { + if set[value] { + return true + } + } + return false + } +} + +func containsAllLabelSetAction(key string, expectedValues []string) LabelFilter { + return func(labels []string) bool { + set := labelSetFor(key, labels) + for _, value := range expectedValues { + if !set[value] { + return false + } + } + return true + } +} + +func consistsOfLabelSetAction(key string, expectedValues []string) LabelFilter { + return func(labels []string) bool { + set := labelSetFor(key, labels) + if len(set) != len(expectedValues) { + return false + } + for _, value := range expectedValues { + if !set[value] { + return false + } + } + return true + } +} + +func isSubsetOfLabelSetAction(key string, expectedValues []string) LabelFilter { + expectedSet := map[string]bool{} + for _, value := range expectedValues { + expectedSet[value] = true + } + return func(labels []string) bool { + set := labelSetFor(key, labels) + for value := range set { + if !expectedSet[value] { + return false + } + } + return true + } +} + type lfToken uint const ( @@ -58,6 +135,9 @@ const ( lfTokenOr lfTokenRegexp lfTokenLabel + lfTokenSetKey + lfTokenSetOperation + lfTokenSetArgument lfTokenEOF ) @@ -71,6 +151,8 @@ func (l lfToken) Precedence() int { return 2 case lfTokenNot: return 3 + case lfTokenSetOperation: + return 4 } return -1 } @@ -93,6 +175,12 @@ func (l lfToken) String() string { return "/regexp/" case lfTokenLabel: return "label" + case lfTokenSetKey: + return "set_key" + case lfTokenSetOperation: + return "set_operation" + case lfTokenSetArgument: + return "set_argument" case lfTokenEOF: return "EOF" } @@ -148,6 +236,35 @@ func (tn *treeNode) constructLabelFilter(input string) (LabelFilter, error) { return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, tn.location, fmt.Sprintf("RegExp compilation error: %s", err)) } return matchLabelRegexAction(re), nil + case lfTokenSetOperation: + tokenSetOperation := strings.ToLower(tn.value) + if tokenSetOperation == "isempty" { + return isEmptyLabelSetAction(tn.leftNode.value), nil + } + if tn.rightNode == nil { + return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, tn.location, fmt.Sprintf("Set operation '%s' is missing an argument.", tn.value)) + } + + rawValues := strings.Split(tn.rightNode.value, ",") + values := make([]string, len(rawValues)) + for i := range rawValues { + values[i] = strings.ToLower(strings.TrimSpace(rawValues[i])) + if strings.ContainsAny(values[i], "&|!,()/") { + return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, tn.rightNode.location, fmt.Sprintf("Invalid label value '%s' in set operation argument.", values[i])) + } else if values[i] == "" { + return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, tn.rightNode.location, "Empty label value in set operation argument.") + } + } + switch tokenSetOperation { + case "containsany": + return containsAnyLabelSetAction(tn.leftNode.value, values), nil + case "containsall": + return containsAllLabelSetAction(tn.leftNode.value, values), nil + case "consistsof": + return consistsOfLabelSetAction(tn.leftNode.value, values), nil + case "issubsetof": + return isSubsetOfLabelSetAction(tn.leftNode.value, values), nil + } } if tn.rightNode == nil { @@ -203,7 +320,17 @@ func (tn *treeNode) toString(indent int) string { return out } +var validSetOperations = map[string]string{ + "containsany": "containsAny", + "containsall": "containsAll", + "consistsof": "consistsOf", + "issubsetof": "isSubsetOf", + "isempty": "isEmpty", +} + func tokenize(input string) func() (*treeNode, error) { + lastToken := lfTokenInvalid + lastValue := "" runes, i := []rune(input), 0 peekIs := func(r rune) bool { @@ -233,6 +360,53 @@ func tokenize(input string) func() (*treeNode, error) { } node := &treeNode{location: i} + defer func() { + lastToken = node.token + lastValue = node.value + }() + + if lastToken == lfTokenSetKey { + //we should get a valid set operation next + value, n := consumeUntil(" )") + if validSetOperations[strings.ToLower(value)] == "" { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, i, fmt.Sprintf("Invalid set operation '%s'.", value)) + } + i += n + node.token, node.value = lfTokenSetOperation, value + return node, nil + } + if lastToken == lfTokenSetOperation { + //we should get an argument next, if we aren't isempty + var arg = "" + origI := i + if runes[i] == '{' { + i += 1 + value, n := consumeUntil("}") + if i+n >= len(runes) { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, i-1, "Missing closing '}' in set operation argument?") + } + i += n + 1 + arg = value + } else { + value, n := consumeUntil("&|!,()/") + i += n + arg = strings.TrimSpace(value) + } + if strings.ToLower(lastValue) == "isempty" && arg != "" { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, origI, fmt.Sprintf("isEmpty does not take arguments, was passed '%s'.", arg)) + } + if arg == "" && strings.ToLower(lastValue) != "isempty" { + if i < len(runes) && runes[i] == '/' { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, origI, "Set operations do not support regular expressions.") + } else { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, origI, fmt.Sprintf("Set operation '%s' requires an argument.", lastValue)) + } + } + // note that we sent an empty SetArgument token if we are isempty + node.token, node.value = lfTokenSetArgument, arg + return node, nil + } + switch runes[i] { case '&': if !peekIs('&') { @@ -264,8 +438,38 @@ func tokenize(input string) func() (*treeNode, error) { i += n + 1 node.token, node.value = lfTokenRegexp, value default: - value, n := consumeUntil("&|!,()/") + value, n := consumeUntil("&|!,()/:") i += n + value = strings.TrimSpace(value) + + //are we the beginning of a set operation? + if i < len(runes) && runes[i] == ':' { + if peekIs(' ') { + if value == "" { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, i, "Missing set key.") + } + i += 1 + //we are the beginning of a set operation + node.token, node.value = lfTokenSetKey, value + return node, nil + } + additionalValue, n := consumeUntil("&|!,()/") + additionalValue = strings.TrimSpace(additionalValue) + if additionalValue == ":" { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, i, "Missing set operation.") + } + i += n + value += additionalValue + } + + valueToCheckForSetOperation := strings.ToLower(value) + for setOperation := range validSetOperations { + idx := strings.Index(valueToCheckForSetOperation, " "+setOperation) + if idx > 0 { + return &treeNode{}, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, i-n+idx+1, fmt.Sprintf("Looks like you are using the set operator '%s' but did not provide a set key. Did you forget the ':'?", validSetOperations[setOperation])) + } + } + node.token, node.value = lfTokenLabel, strings.TrimSpace(value) } return node, nil @@ -307,7 +511,7 @@ LOOP: switch node.token { case lfTokenEOF: break LOOP - case lfTokenLabel, lfTokenRegexp: + case lfTokenLabel, lfTokenRegexp, lfTokenSetKey: if current.rightNode != nil { return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, node.location, "Found two adjacent labels. You need an operator between them.") } @@ -326,6 +530,18 @@ LOOP: node.setLeftNode(nodeToStealFrom.rightNode) nodeToStealFrom.setRightNode(node) current = node + case lfTokenSetOperation: + if current.rightNode == nil { + return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, node.location, fmt.Sprintf("Set operation '%s' missing left hand operand.", node.value)) + } + node.setLeftNode(current.rightNode) + current.setRightNode(node) + current = node + case lfTokenSetArgument: + if current.rightNode != nil { + return nil, GinkgoErrors.SyntaxErrorParsingLabelFilter(input, node.location, fmt.Sprintf("Unexpected set argument '%s'.", node.token)) + } + current.setRightNode(node) case lfTokenCloseGroup: firstUnmatchedOpenNode := current.firstUnmatchedOpenNode() if firstUnmatchedOpenNode == nil { @@ -354,5 +570,14 @@ func ValidateAndCleanupLabel(label string, cl CodeLocation) (string, error) { if strings.ContainsAny(out, "&|!,()/") { return "", GinkgoErrors.InvalidLabel(label, cl) } + if out[0] == ':' { + return "", GinkgoErrors.InvalidLabel(label, cl) + } + if strings.Contains(out, ":") { + components := strings.SplitN(out, ":", 2) + if len(components) < 2 || components[1] == "" { + return "", GinkgoErrors.InvalidLabel(label, cl) + } + } return out, nil } diff --git a/types/label_filter_test.go b/types/label_filter_test.go index cca3d2166..fb3139551 100644 --- a/types/label_filter_test.go +++ b/types/label_filter_test.go @@ -40,6 +40,18 @@ var _ = Describe("LabelFilter", func() { Entry(nil, " || B", 1, "Operator '||' missing left hand operand."), Entry(nil, "&&", 0, "Operator '&&' missing left hand operand."), Entry(nil, "&& || B", 0, "Operator '&&' missing left hand operand."), + Entry(nil, ":", 0, "Missing set operation."), + Entry(nil, ": isEmpty", 0, "Missing set key."), + Entry(nil, "A:", 1, "Missing set operation."), + Entry(nil, "A: B", 3, "Invalid set operation 'B'."), + Entry(nil, "A: B C", 3, "Invalid set operation 'B'."), + Entry(nil, "A isEmpty", 2, "Looks like you are using the set operator 'isEmpty' but did not provide a set key. Did you forget the ':'?"), + Entry(nil, "A bloop containsAny", 8, "Looks like you are using the set operator 'containsAny' but did not provide a set key. Did you forget the ':'?"), + Entry(nil, "A: isEmpty B", 11, "isEmpty does not take arguments, was passed 'B'."), + Entry(nil, "A: containsAny", 3, "Set operation 'containsAny' is missing an argument."), + Entry(nil, "A: containsAny {Foo", 15, "Missing closing '}' in set operation argument?"), + Entry(nil, "A: containsAny /[a]/", 15, "Set operations do not support regular expressions."), + Entry(nil, "/A/: containsAny Foo", 3, "Missing set key."), ) type matchingLabels []string @@ -163,6 +175,42 @@ var _ = Describe("LabelFilter", func() { M("dog", "cat"), M("dog", "cow"), M("cat", "cow", "dog"), M("dog", "orca"), NM("dog"), NM("cow"), NM("cat"), NM("dog", "fruit"), NM("dog", "cup"), ), + Entry("Matching set keys explicitly", "Feature:Alpha", + M("Feature:Alpha"), M("Feature:Alpha", "Feature:Beta"), M("Feature:Beta", "Feature:Alpha"), NM("Feature:Beta"), NM("dog"), + ), + Entry("Set operation: isEmpty", "Feature: isEmpty", + M(), NM("Feature:Beta"), NM("Feature:Beta", "Feature:Alpha"), M("dog"), M("Feature"), M("Widget:Foo"), + ), + Entry("Set operation: containsAny (one)", "Feature: containsAny Alpha", + M("Feature:Alpha"), M("Feature:Alpha", "Feature:Beta"), M("Feature:Beta", "Feature:Alpha"), NM("Feature:Beta"), NM("dog"), NM("Feature"), + ), + Entry("Set operation: containsAny (many)", "Feature: containsAny {Alpha, Beta}", + M("Feature:Alpha"), M("Feature: alpha", "Feature : beta"), M("Feature:Beta", "Feature:Alpha"), M("Feature:Beta"), M("Feature:Alpha", "Feature:Gamma"), NM("Feature:Gamma"), NM("dog"), NM("Feature"), + ), + Entry("Set operation: containsAll (one)", "Feature: containsAll Alpha", + M("Feature:Alpha"), M("Feature:Alpha", "Feature:Beta"), M("Feature:Beta", "Feature:Alpha"), NM("Feature:Beta"), NM("dog"), NM("Feature"), + ), + Entry("Set operation: containsAll (many)", "Feature: containsAll {Alpha, Beta}", + NM("Feature:Alpha"), M("Feature:alpha", "Feature:Beta"), M("Feature:beta", "Feature:Alpha"), M("feature:alpha", "feature: beta", "Feature:Gamma"), NM("Feature:Beta"), NM("Feature:Gamma"), NM("dog"), NM("Feature"), + ), + Entry("Set operation: consistsOf (one)", "Feature: consistsOf Alpha", + M("Feature:Alpha"), NM("Feature:Alpha", "Feature:Beta"), NM("Feature:Beta", "Feature:Alpha"), NM("Feature:Beta"), NM("dog"), NM("Feature"), + ), + Entry("Set operation: consistsOf (many)", "Feature: consistsOf {Alpha, Beta}", + NM("Feature:Alpha"), M("Feature:alpha", "Feature:Beta"), M("Feature:beta", "Feature:Alpha"), NM("feature:alpha", "feature: beta", "Feature:Gamma"), NM("Feature:Beta"), NM("Feature:Gamma"), NM("dog"), NM("Feature"), + ), + Entry("Set operation: isSubsetOf (one)", "Feature: isSubsetOf Alpha", + M("Feature:Alpha"), NM("Feature:Alpha", "Feature:Beta"), NM("Feature:Beta", "Feature:Alpha"), NM("Feature:Beta"), M("dog"), M("Feature"), M(""), + ), + Entry("Set operation: isSubsetOf (many)", "Feature: isSubsetOf {Alpha, Beta}", + M("Feature:Alpha"), M("Feature:alpha", "Feature:Beta"), M("Feature:beta", "Feature:Alpha"), NM("feature:alpha", "feature: beta", "Feature:Gamma"), M("Feature:Beta"), NM("Feature:Gamma"), M("dog"), M("Feature"), M(""), + ), + Entry("Set operations with booleans and explicit labels", "Production && (Feature: isSubsetOf {Alpha, Beta} && !(Feature: isEmpty))", + M("Production", "Feature:Alpha"), M("Production", "Feature:Beta"), M("Production", "Feature:Beta", "Feature:Alpha"), NM("Production", "dog"), NM("Production", "Feature:Gamma", "Feature:Alpha"), NM("Staging", "Feature:Alpha"), NM("Production"), NM(""), + ), + Entry("Set operation: values can have colons", "Feature: containsAny Alpha:1", + M("Feature:Alpha:1"), M("Feature: Alpha:1"), M("Feature :Alpha:1"), M("Feature : Alpha:1"), NM("Feature:Alpha:2"), NM("Feature:Alpha"), NM("Feature:Beta:1"), NM("Feature:Alpha : 1"), + ), ) cl := types.NewCodeLocation(0) @@ -190,6 +238,9 @@ var _ = Describe("LabelFilter", func() { Entry(nil, "cow()", "", types.GinkgoErrors.InvalidLabel("cow()", cl)), Entry(nil, "cow)", "", types.GinkgoErrors.InvalidLabel("cow)", cl)), Entry(nil, "cow/", "", types.GinkgoErrors.InvalidLabel("cow/", cl)), + Entry(nil, ":", "", types.GinkgoErrors.InvalidLabel(":", cl)), + Entry(nil, "Feature:", "", types.GinkgoErrors.InvalidLabel("Feature:", cl)), + Entry(nil, ":Alpha", "", types.GinkgoErrors.InvalidLabel(":Alpha", cl)), ) Describe("MustParseLabelFilter", func() {