Skip to content

Commit dda830a

Browse files
authored
Merge pull request #153 from JoelSpeed/initial-integration-tests
Initial integration tests
2 parents 9992248 + 9b71b6d commit dda830a

File tree

21 files changed

+5255
-1
lines changed

21 files changed

+5255
-1
lines changed

.golangci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ linters:
101101
- gocyclo
102102
- gosec
103103
# Exclude some linters from running on tests files.
104-
path: _test\.go
104+
path: _test\.go|tests
105105
- linters:
106106
- all
107107
path: testdata
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package integration
17+
18+
import (
19+
"context"
20+
"os"
21+
"os/exec"
22+
"path/filepath"
23+
"testing"
24+
25+
. "github.com/onsi/ginkgo/v2"
26+
. "github.com/onsi/gomega"
27+
)
28+
29+
var (
30+
ctx = context.Background()
31+
binPath string
32+
)
33+
34+
func TestIntegration(t *testing.T) {
35+
RegisterFailHandler(Fail)
36+
RunSpecs(t, "Integration")
37+
}
38+
39+
var _ = BeforeSuite(func() {
40+
tempDir, err := os.MkdirTemp("", "kube-api-linter-integration-")
41+
Expect(err).ToNot(HaveOccurred())
42+
43+
binPath = filepath.Join(tempDir, "golangci-lint")
44+
45+
_, err = exec.CommandContext(ctx, "go", "build", "-o", binPath, "../../cmd/golangci-lint-kube-api-linter/").CombinedOutput()
46+
Expect(err).ToNot(HaveOccurred())
47+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package integration
17+
18+
import (
19+
. "github.com/onsi/ginkgo/v2"
20+
"sigs.k8s.io/kube-api-linter/tests/integration/runner"
21+
)
22+
23+
var _ = It("With all ok data", func() {
24+
runner := runner.NewRunnerBuilder().
25+
WithBinPath(binPath).
26+
WithExitCode(0).
27+
Runner()
28+
29+
runner.RunTestsFromDir("testdata/all_ok")
30+
})
31+
32+
var _ = It("With default configurations", func() {
33+
runner := runner.NewRunnerBuilder().
34+
WithBinPath(binPath).
35+
WithExitCode(1).
36+
Runner()
37+
38+
runner.RunTestsFromDir("testdata/default_configurations")
39+
})
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package runner
17+
18+
import (
19+
"encoding/json"
20+
"fmt"
21+
"go/parser"
22+
"go/token"
23+
"maps"
24+
"os"
25+
"path/filepath"
26+
"regexp"
27+
"sort"
28+
"strconv"
29+
"strings"
30+
"text/scanner"
31+
32+
"github.com/golangci/golangci-lint/v2/pkg/result"
33+
. "github.com/onsi/ginkgo/v2"
34+
. "github.com/onsi/gomega"
35+
)
36+
37+
const keyword = "want"
38+
39+
type jsonResult struct {
40+
Issues []*result.Issue
41+
}
42+
43+
type expectation struct {
44+
kind string // either "fact" or "diagnostic"
45+
name string // name of object to which fact belongs, or "package" ("fact" only)
46+
rx *regexp.Regexp
47+
}
48+
49+
type key struct {
50+
file string
51+
line int
52+
}
53+
54+
// Analyze analyzes the test expectations ('want').
55+
// Inspired by:
56+
// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L655-L672
57+
func analyze(sourcePath string, rawData []byte) {
58+
GinkgoHelper()
59+
60+
want, err := getWantFromSourcePath(sourcePath)
61+
Expect(err).ToNot(HaveOccurred())
62+
63+
var reportData jsonResult
64+
65+
Expect(json.Unmarshal(rawData, &reportData)).To(Succeed())
66+
67+
var failures []string
68+
for _, issue := range reportData.Issues {
69+
failures = append(failures, checkMessage(want, issue.Pos, "diagnostic", issue.FromLinter, issue.Text)...)
70+
}
71+
72+
var surplus []string
73+
74+
for key, expects := range want {
75+
for _, exp := range expects {
76+
err := fmt.Sprintf("%s:%d: no %s was reported matching %#q", key.file, key.line, exp.kind, exp.rx)
77+
surplus = append(surplus, err)
78+
}
79+
}
80+
81+
sort.Strings(surplus)
82+
failures = append(failures, surplus...)
83+
84+
Expect(failures).To(BeEmpty())
85+
}
86+
87+
// Inspired by:
88+
// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L524-L553
89+
func parseComments(sourcePath string, fileData []byte) (map[key][]expectation, error) {
90+
fset := token.NewFileSet()
91+
92+
// the error is ignored to let 'typecheck' handle compilation error
93+
f, _ := parser.ParseFile(fset, sourcePath, fileData, parser.ParseComments)
94+
95+
want := make(map[key][]expectation)
96+
97+
for _, comment := range f.Comments {
98+
for _, c := range comment.List {
99+
text := strings.TrimPrefix(c.Text, "//")
100+
if text == c.Text { // not a //-comment.
101+
text = strings.TrimPrefix(text, "/*")
102+
text = strings.TrimSuffix(text, "*/")
103+
}
104+
105+
if i := strings.Index(text, "// "+keyword); i >= 0 {
106+
text = text[i+len("// "):]
107+
}
108+
109+
posn := fset.Position(c.Pos())
110+
111+
text = strings.TrimSpace(text)
112+
113+
if rest := strings.TrimPrefix(text, keyword); rest != text {
114+
delta, expects, err := parseExpectations(rest)
115+
if err != nil {
116+
return nil, err
117+
}
118+
119+
want[key{sourcePath, posn.Line + delta}] = expects
120+
}
121+
}
122+
}
123+
124+
return want, nil
125+
}
126+
127+
// Inspired by:
128+
// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L685-L745
129+
//
130+
//nolint:cyclop
131+
func parseExpectations(text string) (lineDelta int, expects []expectation, err error) {
132+
var scanErr string
133+
134+
sc := new(scanner.Scanner).Init(strings.NewReader(text))
135+
sc.Error = func(_ *scanner.Scanner, msg string) {
136+
scanErr = msg // e.g. bad string escape
137+
}
138+
sc.Mode = scanner.ScanIdents | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanInts
139+
140+
scanRegexp := func(tok rune) (*regexp.Regexp, error) {
141+
if tok != scanner.String && tok != scanner.RawString {
142+
return nil, fmt.Errorf("got %s, want regular expression",
143+
scanner.TokenString(tok))
144+
}
145+
146+
pattern, _ := strconv.Unquote(sc.TokenText()) // can't fail
147+
148+
return regexp.Compile(pattern)
149+
}
150+
151+
for {
152+
tok := sc.Scan()
153+
switch tok {
154+
case '+':
155+
tok = sc.Scan()
156+
if tok != scanner.Int {
157+
return 0, nil, fmt.Errorf("got +%s, want +Int", scanner.TokenString(tok))
158+
}
159+
160+
lineDelta, _ = strconv.Atoi(sc.TokenText())
161+
case scanner.String, scanner.RawString:
162+
rx, err := scanRegexp(tok)
163+
if err != nil {
164+
return 0, nil, err
165+
}
166+
167+
expects = append(expects, expectation{"diagnostic", "", rx})
168+
169+
case scanner.Ident:
170+
name := sc.TokenText()
171+
172+
tok = sc.Scan()
173+
if tok != ':' {
174+
return 0, nil, fmt.Errorf("got %s after %s, want ':'",
175+
scanner.TokenString(tok), name)
176+
}
177+
178+
tok = sc.Scan()
179+
180+
rx, err := scanRegexp(tok)
181+
if err != nil {
182+
return 0, nil, err
183+
}
184+
185+
expects = append(expects, expectation{"diagnostic", name, rx})
186+
187+
case scanner.EOF:
188+
if scanErr != "" {
189+
return 0, nil, fmt.Errorf("%s", scanErr)
190+
}
191+
192+
return lineDelta, expects, nil
193+
194+
default:
195+
return 0, nil, fmt.Errorf("unexpected %s", scanner.TokenString(tok))
196+
}
197+
}
198+
}
199+
200+
// Inspired by:
201+
// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L594-L617
202+
func checkMessage(want map[key][]expectation, posn token.Position, kind, name, message string) []string {
203+
GinkgoHelper()
204+
205+
if !filepath.IsAbs(posn.Filename) {
206+
// Output from golangci-lint prefixes the filename with several layers of "../"
207+
// and then has the absolute path to the file.
208+
// Strip the "../" so that we use the absolute path.
209+
posn.Filename = "/" + strings.ReplaceAll(posn.Filename, "../", "")
210+
}
211+
212+
k := key{posn.Filename, posn.Line}
213+
expects := want[k]
214+
215+
var unmatched []string
216+
217+
for i, exp := range expects {
218+
if exp.kind == kind && (exp.name == "" || exp.name == name) {
219+
if exp.rx.MatchString(message) {
220+
// matched: remove the expectation.
221+
expects[i] = expects[len(expects)-1]
222+
expects = expects[:len(expects)-1]
223+
want[k] = expects
224+
225+
return []string{}
226+
}
227+
228+
unmatched = append(unmatched, fmt.Sprintf("%#q", exp.rx))
229+
}
230+
}
231+
232+
if unmatched == nil {
233+
return []string{fmt.Sprintf("%v: unexpected %s: %v", posn, kind, message)}
234+
} else {
235+
return []string{fmt.Sprintf("%v: %s %q does not match pattern %s", posn, kind, message, strings.Join(unmatched, " or "))}
236+
}
237+
}
238+
239+
// getWantFromSourcePath combines all source files from the directory to get a complete list of `want` directives.
240+
func getWantFromSourcePath(sourcePath string) (map[key][]expectation, error) {
241+
var err error
242+
243+
// Make everything absolute so that we always compare absolute paths with the wants.
244+
sourcePath, err = filepath.Abs(sourcePath)
245+
if err != nil {
246+
return nil, fmt.Errorf("error getting absolute path: %w", err)
247+
}
248+
249+
var sourcePaths []string
250+
251+
sourcePathInfo, err := os.Stat(sourcePath)
252+
if err != nil {
253+
return nil, fmt.Errorf("error getting file info: %w", err)
254+
}
255+
256+
if sourcePathInfo.IsDir() {
257+
sourcePaths, err = filepath.Glob(sourcePath + "/*.go")
258+
if err != nil {
259+
return nil, fmt.Errorf("error searching for go files in dir %s: %w", sourcePathInfo.Name(), err)
260+
}
261+
} else {
262+
sourcePaths = []string{sourcePath}
263+
}
264+
265+
want := make(map[key][]expectation)
266+
267+
for _, path := range sourcePaths {
268+
fileData, err := os.ReadFile(path)
269+
if err != nil {
270+
return nil, fmt.Errorf("error reading file %s: %w", path, err)
271+
}
272+
273+
fileWant, err := parseComments(path, fileData)
274+
if err != nil {
275+
return nil, fmt.Errorf("could not parse file %s comments: %w", path, err)
276+
}
277+
278+
maps.Copy(want, fileWant)
279+
}
280+
281+
return want, nil
282+
}

0 commit comments

Comments
 (0)