diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d1930925d..048b9bd80 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -54,6 +54,12 @@ jobs: path: ./tools/gotestloghelper/ - name: k8s-test-runner path: ./k8s-test-runner/ + - name: testlistgenerator + path: ./tools/testlistgenerator/ + - name: ecrimagefetcher + path: ./tools/ecrimagefetcher/ + - name: ghlatestreleasechecker + path: ./tools/ghlatestreleasechecker/ steps: - name: Check out Code uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 diff --git a/.github/workflows/release-tools.yaml b/.github/workflows/release-tools.yaml index 0e7f08b29..3deee8da4 100644 --- a/.github/workflows/release-tools.yaml +++ b/.github/workflows/release-tools.yaml @@ -8,7 +8,13 @@ jobs: tools: strategy: matrix: - tool: [tools/gotestloghelper] + tool: + [ + tools/gotestloghelper, + tools/testlistgenerator, + tools/ecrimagefetcher, + tools/ghlatestreleasechecker, + ] name: Release ${{ matrix.tool }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f4756971d..c67490eba 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,6 +14,12 @@ jobs: path: ./ - name: gotestloghelper path: ./tools/gotestloghelper/ + - name: testlistgenerator + path: ./tools/testlistgenerator/ + - name: ecrimagefetcher + path: ./tools/ecrimagefetcher/ + - name: ghlatestreleasechecker + path: ./tools/ghlatestreleasechecker/ runs-on: ubuntu-latest name: ${{ matrix.project.name }} unit tests steps: diff --git a/concurrency/executor_test.go b/concurrency/executor_test.go index bcc6a83d0..5588c8f5e 100644 --- a/concurrency/executor_test.go +++ b/concurrency/executor_test.go @@ -142,9 +142,10 @@ func TestExecute(t *testing.T) { func TestExecuteFailFast(t *testing.T) { type tc struct { - name string - executor *concurrency.ConcurrentExecutor[int, result, config] - failFast bool + name string + executor *concurrency.ConcurrentExecutor[int, result, config] + processorFn func(resultCh chan result, errCh chan error, keyNum int, payload config) + failFast bool } tcs := []tc{ @@ -152,11 +153,18 @@ func TestExecuteFailFast(t *testing.T) { name: "fail fast enabled", executor: concurrency.NewConcurrentExecutor[int, result, config](logging.GetTestLogger(t)), failFast: true, + processorFn: func(resultCh chan result, errCh chan error, keyNum int, payload config) { + time.Sleep(10 * time.Millisecond) + errCh <- errors.New("always fail, fail fast enabled") + }, }, { name: "fail fast disabled", executor: concurrency.NewConcurrentExecutor(logging.GetTestLogger(t), concurrency.WithoutFailFast[int, result, config]()), failFast: false, + processorFn: func(resultCh chan result, errCh chan error, keyNum int, payload config) { + errCh <- errors.New("always fail, fail fast disabled") + }, }, } @@ -165,10 +173,6 @@ func TestExecuteFailFast(t *testing.T) { tc := tc t.Parallel() - processorFn := func(resultCh chan result, errCh chan error, keyNum int, payload config) { - errCh <- errors.New("always fail") - } - expectedExecutions := 1000 configs := []config{} @@ -176,10 +180,11 @@ func TestExecuteFailFast(t *testing.T) { configs = append(configs, struct{}{}) } - results, err := tc.executor.Execute(expectedExecutions, configs, processorFn) + results, err := tc.executor.Execute(100, configs, tc.processorFn) require.Error(t, err, "No error returned when executing concurrently") require.Len(t, results, 0, "Expected no results") if tc.failFast { + fmt.Println(len(tc.executor.GetErrors())) require.Less(t, len(tc.executor.GetErrors()), expectedExecutions, "With fail fast enabled not all tasks should be executed") } else { require.Equal(t, len(tc.executor.GetErrors()), expectedExecutions, "With fail fast disabled all tasks should be executed") diff --git a/tools/ecrimagefetcher/Makefile b/tools/ecrimagefetcher/Makefile new file mode 100644 index 000000000..a01538a70 --- /dev/null +++ b/tools/ecrimagefetcher/Makefile @@ -0,0 +1,5 @@ +lint: + golangci-lint --color=always run ./... --fix -v + +test_unit: + go test -timeout 5m -json -cover -covermode=count -coverprofile=unit-test-coverage.out ./... 2>&1 | tee /tmp/gotest.log diff --git a/tools/ecrimagefetcher/README.md b/tools/ecrimagefetcher/README.md new file mode 100644 index 000000000..23be4e1c2 --- /dev/null +++ b/tools/ecrimagefetcher/README.md @@ -0,0 +1,61 @@ +# AWS ECR Image Fetcher + +This Go script fetches the latest image tags from an AWS ECR repository based on specified criteria such as a grep string and optional semantic version constraints. It sorts and filters the tags and returns a specified number of the latest ones (according to semantic versioning). + +## Usage + +```bash +go run main.go [] +``` + +### Example + +```bash +go run main.go 'my-repo' '^v[0-9]+\.[0-9]+\.[0-9]+$' 5 '>=1.0.0, <2.0.0' +``` + +## Command Line Arguments + +- ``: The name of the ECR repository. +- ``: A regex string to filter the tags. +- ``: The number of latest tags to return. +- `[]`: Optional semantic version constraints to filter tags further. + +## Output + +- The script prints the specified number of latest image tags from the repository that match the given criteria, formatted as `repository_name:tag`, e.g.`hyperledger/besu:24.3,hyperledger/besu:24.2`. +- If the number of matching tags is less than the requested count, a warning is printed to stderr, and the available tags are returned. + +## Error Handling + +The script will panic and display error messages in the following scenarios: + +- Insufficient command line arguments. +- Empty or invalid `repository_name`, `grep_string`, or `count`. +- Invalid regex for `grep_string`. +- Invalid integer value for `count`. +- Invalid semantic version constraints. +- Errors in fetching or parsing the image details from AWS ECR. + +## Detailed Steps + +1. **Argument Parsing and Validation**: + - The script checks that at least 4 command line arguments are provided. + - Validates the format and values of `repository_name`, `grep_string`, and `count`. + - Optionally validates semantic version constraints if provided. +2. **Fetch Image Details**: + - Constructs and executes an AWS CLI command to describe images in the specified ECR repository. + - Parses the JSON output to extract image tags. +3. **Filter and Sort Tags**: + - Filters tags based on the `grep_string` regex. + - Optionally filters tags based on semantic version constraints. + - Sorts the tags in descending order. +4. **Return Latest Tags**: + - Constructs the output of the latest tags formatted as `repository_name:tag`. + - Prints the result to stdout. + +## Notes + +- Ensure AWS CLI is configured and you have the necessary permissions to access the ECR repository. +- The environment variable `AWS_REGION` must be set to the appropriate AWS region. +- Semantic version constraints are optional and can be used to further filter the tags. diff --git a/tools/ecrimagefetcher/go.mod b/tools/ecrimagefetcher/go.mod new file mode 100644 index 000000000..d60185395 --- /dev/null +++ b/tools/ecrimagefetcher/go.mod @@ -0,0 +1,16 @@ +module github.com/smartcontractkit/chainlink-testing-framework/tools/latestecrimages + +go 1.21.7 + +require ( + github.com/Masterminds/semver/v3 v3.2.1 + github.com/itchyny/gojq v0.12.16 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/ecrimagefetcher/go.sum b/tools/ecrimagefetcher/go.sum new file mode 100644 index 000000000..c31adeb54 --- /dev/null +++ b/tools/ecrimagefetcher/go.sum @@ -0,0 +1,16 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/itchyny/gojq v0.12.16 h1:yLfgLxhIr/6sJNVmYfQjTIv0jGctu6/DgDoivmxTr7g= +github.com/itchyny/gojq v0.12.16/go.mod h1:6abHbdC2uB9ogMS38XsErnfqJ94UlngIJGlRAIj4jTM= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/ecrimagefetcher/main.go b/tools/ecrimagefetcher/main.go new file mode 100644 index 000000000..91c19d80c --- /dev/null +++ b/tools/ecrimagefetcher/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/itchyny/gojq" +) + +func fetchImageDetails(repositoryName string) ([]byte, error) { + // #nosec G204 + cmd := exec.Command("aws", "ecr", "describe-images", "--repository-name", repositoryName, "--region", os.Getenv("AWS_REGION"), "--output", "json", "--query", "imageDetails[?imageTags!=`null` && imageTags!=`[]`]") + return cmd.Output() +} + +func parseImageTags(output []byte, grepString string, constraints *semver.Constraints) ([]string, error) { + var imageDetails []interface{} + if err := json.Unmarshal(output, &imageDetails); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON: %w", err) + } + + query, err := gojq.Parse(".[] | .imageTags[0]") + if err != nil { + return nil, fmt.Errorf("failed to parse gojq query: %w", err) + } + + var tags []string + iter := query.Run(imageDetails) + for { + tag, ok := iter.Next() + if !ok { + break + } + if tagStr, ok := tag.(string); ok { + tags = append(tags, tagStr) + } else if err, ok := tag.(error); ok { + return nil, fmt.Errorf("failed to run gojq query: %w", err) + } + } + + sort.Sort(sort.Reverse(sort.StringSlice(tags))) + + re, err := regexp.Compile(grepString) + if err != nil { + return nil, fmt.Errorf("failed to compile regex: %w", err) + } + + var filteredTags []string + for _, tag := range tags { + if re.MatchString(tag) { + ignore := false + if constraints != nil { + version, err := semver.NewVersion(tag) + if err != nil { + return nil, fmt.Errorf("failed to parse version: %w", err) + } + if !constraints.Check(version) { + ignore = true + } + } + + if !ignore { + filteredTags = append(filteredTags, tag) + } + } + } + + return filteredTags, nil +} + +func getLatestImages(fetchFunc func(string) ([]byte, error), repositoryName, grepString string, count int, constraints *semver.Constraints) (string, error) { + output, err := fetchFunc(repositoryName) + if err != nil { + return "", fmt.Errorf("failed to describe images: %w", err) + } + + tags, err := parseImageTags(output, grepString, constraints) + if err != nil { + return "", fmt.Errorf("failed to parse image tags: %w", err) + } + + constraintsText := "(none)" + if constraints != nil { + constraintsText = constraints.String() + } + + if len(tags) == 0 { + panic(fmt.Errorf("error: no tags found for repository '%s' given the version constraints '%s'", repositoryName, constraintsText)) + } + + if count > len(tags) { + _, _ = fmt.Fprintf(os.Stderr, "Warning: failed to find %d tags given the version constraints '%s'. Found only %d tag(s)\n", count, constraintsText, len(tags)) + count = len(tags) + } + + var imagesArr []string + for i := 0; i < count; i++ { + imagesArr = append(imagesArr, fmt.Sprintf("%s:%s", repositoryName, tags[i])) + } + + images := strings.Join(imagesArr, ",") + return images, nil +} + +func main() { + if err := validateInputs(); err != nil { + panic(err) + } + + repositoryName := os.Args[1] + grepString := os.Args[2] + count, err := strconv.Atoi(os.Args[3]) + if err != nil { + panic(fmt.Errorf("error: count must be an integer, but %s is not an integer", os.Args[3])) + } + + var constraints *semver.Constraints + if len(os.Args) == 5 { + constraints, err = semver.NewConstraint(os.Args[4]) + if err != nil { + panic(fmt.Errorf("error: invalid semver constraint: %v", err)) + } + } + + images, err := getLatestImages(fetchImageDetails, repositoryName, grepString, count, constraints) + if err != nil { + panic(fmt.Errorf("error getting latest images: %v", err)) + } + + fmt.Println(images) +} + +func validateInputs() error { + if len(os.Args) < 4 { + return errors.New("usage: []") + } + + if os.Args[1] == "" { + return errors.New("error: repository_name cannot be empty") + } + + if os.Args[2] == "" { + return errors.New("error: grep_string cannot be empty") + } + + if _, err := regexp.Compile(os.Args[2]); err != nil { + return errors.New("error: grep_string is not a valid regex") + } + + if _, err := strconv.Atoi(os.Args[3]); err != nil { + return fmt.Errorf("error: count must be an integer, but %s is not an integer", os.Args[3]) + } + + if len(os.Args) == 5 && os.Args[4] != "" { + for _, semVerConstraint := range strings.Split(os.Args[4], ",") { + if semVerConstraint == "" { + return errors.New("error: semver constraint cannot be empty") + } + _, err := semver.NewConstraint(semVerConstraint) + if err != nil { + return fmt.Errorf("error: invalid semver constraint: %v", err) + } + } + } + + return nil +} diff --git a/tools/ecrimagefetcher/main_test.go b/tools/ecrimagefetcher/main_test.go new file mode 100644 index 000000000..d43a17bd3 --- /dev/null +++ b/tools/ecrimagefetcher/main_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "fmt" + "os" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/stretchr/testify/require" +) + +func mockFetchImageDetailsSuccess(_ string) ([]byte, error) { + return []byte(`[ + { + "imageTags": ["v1.0.0"] + }, + { + "imageTags": ["v1.1.0"] + }, + { + "imageTags": ["v1.2.0"] + } + ]`), nil +} + +func mockFetchImageDetailsError(_ string) ([]byte, error) { + return nil, fmt.Errorf("failed to describe images") +} + +func TestGetLatestImages(t *testing.T) { + t.Run("Success", func(t *testing.T) { + images, err := getLatestImages(mockFetchImageDetailsSuccess, "test-repo", "v1.*", 2, nil) + require.NoError(t, err) + require.Equal(t, "test-repo:v1.2.0,test-repo:v1.1.0", images) + }) + + t.Run("ErrorFetchingDetails", func(t *testing.T) { + _, err := getLatestImages(mockFetchImageDetailsError, "test-repo", "v1.*", 2, nil) + require.Error(t, err) + require.Equal(t, "failed to describe images: failed to describe images", err.Error()) + }) + + t.Run("ErrorParsingTags", func(t *testing.T) { + _, err := getLatestImages(mockFetchImageDetailsSuccess, "test-repo", "invalid[regex", 2, nil) + require.Error(t, err) + require.Equal(t, "failed to parse image tags: failed to compile regex: error parsing regexp: missing closing ]: `[regex`", err.Error()) + }) + + t.Run("InsufficientTags", func(t *testing.T) { + images, err := getLatestImages(mockFetchImageDetailsSuccess, "test-repo", "v1.*", 5, nil) + require.NoError(t, err) + require.Equal(t, "test-repo:v1.2.0,test-repo:v1.1.0,test-repo:v1.0.0", images) + }) + + t.Run("WithConstraint", func(t *testing.T) { + constraints, err := semver.NewConstraint(" []") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("EmptyRepositoryName", func(t *testing.T) { + os.Args = []string{"main", "", "v1.*", "2"} + expectedError := errors.New("error: repository_name cannot be empty") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("EmptyGrepString", func(t *testing.T) { + os.Args = []string{"main", "test-repo", "", "2"} + expectedError := errors.New("error: grep_string cannot be empty") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("InvalidGrepString", func(t *testing.T) { + os.Args = []string{"main", "test-repo", "invalid[regex", "2"} + expectedError := errors.New("error: grep_string is not a valid regex") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("NonIntegerCount", func(t *testing.T) { + os.Args = []string{"main", "test-repo", "v1.*", "two"} + expectedError := fmt.Errorf("error: count must be an integer, but %s is not an integer", "two") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("EmptyConstraint", func(t *testing.T) { + os.Args = []string{"main", "test-repo", "v1.*", "2", ">=v1.0.0,"} + expectedError := errors.New("error: semver constraint cannot be empty") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("InvalidConstraint", func(t *testing.T) { + os.Args = []string{"main", "test-repo", "v1.*", "2", "asdasd87"} + expectedError := errors.New("error: invalid semver constraint: improper constraint: asdasd87") + require.EqualError(t, validateInputs(), expectedError.Error()) + }) + + t.Run("Success", func(t *testing.T) { + os.Args = []string{"main", "test-repo", "v1.*", "2"} + require.NoError(t, validateInputs()) + }) +} diff --git a/tools/ecrimagefetcher/package.json b/tools/ecrimagefetcher/package.json new file mode 100644 index 000000000..20c0f28b1 --- /dev/null +++ b/tools/ecrimagefetcher/package.json @@ -0,0 +1,5 @@ +{ + "name": "latestecrimages", + "description": "Tool that fetches latest ECR images for a given repository", + "version": "1.0.0" +} diff --git a/tools/ghlatestreleasechecker/Makefile b/tools/ghlatestreleasechecker/Makefile new file mode 100644 index 000000000..a01538a70 --- /dev/null +++ b/tools/ghlatestreleasechecker/Makefile @@ -0,0 +1,5 @@ +lint: + golangci-lint --color=always run ./... --fix -v + +test_unit: + go test -timeout 5m -json -cover -covermode=count -coverprofile=unit-test-coverage.out ./... 2>&1 | tee /tmp/gotest.log diff --git a/tools/ghlatestreleasechecker/README.md b/tools/ghlatestreleasechecker/README.md new file mode 100644 index 000000000..19acefa14 --- /dev/null +++ b/tools/ghlatestreleasechecker/README.md @@ -0,0 +1,56 @@ +# GitHub Latest Release Checker + +This Go script checks if the latest release of a given GitHub repository is within a specified number of days from today. It fetches the latest release information from the GitHub API and compares the release date with the current date. + +## Usage + +```bash +go run main.go +``` + +### Example + +```bash +go run main.go 'owner/repo' 30 +``` + +## Command Line Arguments + +- ``: The GitHub repository in the format `owner/repo`. +- ``: The number of days to check if the latest release is within. + +## Output + +- The script prints the tag name of the latest release if it was published within the specified number of days. +- If the release is older than the specified number of days, it prints `none`. + +## Error Handling + +The script will panic and display error messages in the following scenarios: + +- Insufficient command line arguments. +- Invalid repository name format (must be `owner/repo`). +- Non-integer value for the `days` argument. +- Errors in fetching or parsing the latest release information from the GitHub API. +- Unexpected status codes from the GitHub API response. + +## Detailed Steps + +1. **Argument Parsing and Validation**: + - The script checks that at least 3 command line arguments are provided. + - Validates the format of the repository name. + - Ensures the `days` argument is an integer. +2. **Fetch Latest Release**: + - Constructs the API URL for the latest release of the specified repository. + - Makes an HTTP GET request to the GitHub API. + - Parses the JSON response to extract the latest release information. +3. **Check Release Date**: + - Compares the release date with the current date to determine if it is within the specified number of days. +4. **Output Result**: + - Prints the tag name of the latest release if it is recent. + - Prints `none` if the release is older than the specified number of days. + +## Notes + +- Ensure the repository name is in the format `owner/repo`. +- The GitHub API has rate limits; be mindful of making too many requests in a short period. diff --git a/tools/ghlatestreleasechecker/go.mod b/tools/ghlatestreleasechecker/go.mod new file mode 100644 index 000000000..ee61236ca --- /dev/null +++ b/tools/ghlatestreleasechecker/go.mod @@ -0,0 +1,11 @@ +module github.com/smartcontractkit/chainlink-testing-framework/tools/ghlatestreleasechecker + +go 1.21.7 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/ghlatestreleasechecker/go.sum b/tools/ghlatestreleasechecker/go.sum new file mode 100644 index 000000000..60ce688a0 --- /dev/null +++ b/tools/ghlatestreleasechecker/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/ghlatestreleasechecker/main.go b/tools/ghlatestreleasechecker/main.go new file mode 100644 index 000000000..4c9cfdffa --- /dev/null +++ b/tools/ghlatestreleasechecker/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// Release represents a GitHub release +type Release struct { + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` +} + +var repoURL = "https://api.github.com/repos/%s/releases/latest" + +var client = &http.Client{} + +func main() { + if err := validateInputs(); err != nil { + panic(err) + } + + release, err := getLatestRelease(fmt.Sprintf(repoURL, os.Args[1]), client) + if err != nil { + panic(fmt.Errorf("error fetching release: %v", err)) + } + + days, err := strconv.Atoi(os.Args[2]) + if err != nil { + panic(fmt.Errorf("error parsing days: %v", err)) + } + + if isReleaseRecent(release.PublishedAt, days) { + fmt.Println(release.TagName) + } else { + fmt.Println("none") + } +} + +// getLatestRelease fetches the latest release from the given repository URL +func getLatestRelease(url string, client *http.Client) (*Release, error) { + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var release Release + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&release); err != nil { + return nil, err + } + + return &release, nil +} + +// isReleaseRecent checks if the release date is within the given number of days from today +func isReleaseRecent(publishedAt time.Time, days int) bool { + duration := time.Since(publishedAt) + return duration.Hours() <= float64(days*24) +} + +func validateInputs() error { + if len(os.Args) < 3 { + return errors.New("usage: go run main.go ") + } + + if os.Args[1] == "" { + return errors.New("error: repository_name cannot be empty") + } + + if len(strings.Split(os.Args[1], "/")) != 2 { + return errors.New("error: repository_name must be in the format /") + } + + if _, err := strconv.Atoi(os.Args[2]); err != nil { + return fmt.Errorf("error: days must be an integer, but '%s' is not an integer", os.Args[2]) + } + + return nil +} diff --git a/tools/ghlatestreleasechecker/main_test.go b/tools/ghlatestreleasechecker/main_test.go new file mode 100644 index 000000000..2a5a88ee2 --- /dev/null +++ b/tools/ghlatestreleasechecker/main_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMainFunction(t *testing.T) { + t.Run("MissingArguments", func(t *testing.T) { + os.Args = []string{"main"} + require.PanicsWithError(t, "usage: go run main.go ", func() { + main() + }) + }) + + t.Run("InvalidDaysArgument", func(t *testing.T) { + os.Args = []string{"main", "some/repo", "invalid"} + require.PanicsWithError(t, "error: days must be an integer, but 'invalid' is not an integer", func() { + main() + }) + }) + + t.Run("ValidArgumentsNoRelease", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + oldRepoURL := repoURL + repoURL = server.URL + "/%s/releases/latest" + defer func() { repoURL = oldRepoURL }() + + os.Args = []string{"main", "some/repo", "30"} + require.PanicsWithError(t, "error fetching release: unexpected status code: 404\n", func() { + main() + }) + }) + + t.Run("ValidArgumentsWithRelease", func(t *testing.T) { + release := Release{ + TagName: "v1.0.0", + PublishedAt: time.Now().AddDate(0, 0, -1), // 1 day ago + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewEncoder(w).Encode(release) + require.NoError(t, err) + })) + defer server.Close() + + oldRepoURL := repoURL + repoURL = server.URL + "/%s/releases/latest" + defer func() { repoURL = oldRepoURL }() + + os.Args = []string{"main", "some/repo", "30"} + output := captureOutput(func() { + main() + }) + require.Contains(t, "v1.0.0", output) + }) + + t.Run("ValidArgumentsWithOldRelease", func(t *testing.T) { + release := Release{ + TagName: "v1.0.0", + PublishedAt: time.Now().AddDate(0, 0, -10), // 1 day ago + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewEncoder(w).Encode(release) + require.NoError(t, err) + })) + defer server.Close() + + oldRepoURL := repoURL + repoURL = server.URL + "/%s/releases/latest" + defer func() { repoURL = oldRepoURL }() + + os.Args = []string{"main", "some/repo", "9"} + output := captureOutput(func() { + main() + }) + require.Equal(t, "none\n", output) + }) +} + +func TestGetLatestRelease(t *testing.T) { + t.Run("Success", func(t *testing.T) { + release := Release{ + TagName: "v1.0.0", + PublishedAt: time.Now(), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewEncoder(w).Encode(release) + require.NoError(t, err) + })) + defer server.Close() + + result, err := getLatestRelease(server.URL, http.DefaultClient) + require.NoError(t, err) + require.Equal(t, release.TagName, result.TagName) + require.True(t, release.PublishedAt.Equal(result.PublishedAt)) + }) + + t.Run("Non200StatusCode", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + _, err := getLatestRelease(server.URL, http.DefaultClient) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected status code: 404") + }) + + t.Run("InvalidJSON", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintln(w, "invalid json") + require.NoError(t, err) + })) + defer server.Close() + + _, err := getLatestRelease(server.URL, http.DefaultClient) + require.Error(t, err) + }) +} + +func TestIsReleaseRecent(t *testing.T) { + t.Run("RecentRelease", func(t *testing.T) { + publishedAt := time.Now().AddDate(0, 0, -5) // 5 days ago + days := 10 + require.True(t, isReleaseRecent(publishedAt, days)) + }) + + t.Run("OldRelease", func(t *testing.T) { + publishedAt := time.Now().AddDate(0, 0, -15) // 15 days ago + days := 10 + require.False(t, isReleaseRecent(publishedAt, days)) + }) +} + +func captureOutput(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + _ = w.Close() + os.Stdout = old + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} diff --git a/tools/ghlatestreleasechecker/package.json b/tools/ghlatestreleasechecker/package.json new file mode 100644 index 000000000..9eae68a08 --- /dev/null +++ b/tools/ghlatestreleasechecker/package.json @@ -0,0 +1,5 @@ +{ + "name": "ghlatestreleasechecker", + "description": "Tool that checks whether a new stable release of a project is available on GitHub", + "version": "1.0.0" +} diff --git a/tools/testlistgenerator/Makefile b/tools/testlistgenerator/Makefile new file mode 100644 index 000000000..a01538a70 --- /dev/null +++ b/tools/testlistgenerator/Makefile @@ -0,0 +1,5 @@ +lint: + golangci-lint --color=always run ./... --fix -v + +test_unit: + go test -timeout 5m -json -cover -covermode=count -coverprofile=unit-test-coverage.out ./... 2>&1 | tee /tmp/gotest.log diff --git a/tools/testlistgenerator/README.md b/tools/testlistgenerator/README.md new file mode 100644 index 000000000..f93e02635 --- /dev/null +++ b/tools/testlistgenerator/README.md @@ -0,0 +1,75 @@ +# Test List Generator + +This Go script builds a JSON file containing tests to be run for a given product and Ethereum implementation. It reads command line arguments to construct and append entries to a JSON file. + +## Usage + +```bash +go run main.go +``` + +### Example + +```bash +go run main.go 'test_list.json' 'ocr' 'TestOCR.*' './smoke/ocr_test.go' 'besu' 'hyperledger/besu:21.0.0,hyperledger/besu:22.0.0' +``` + +## Output + +The script generates or updates a JSON file with entries structured as follows: + +```json +{ + "tests": [ + { + "product": "ocr", + "test_regex": "TestOCR.*", + "file": "./smoke/ocr_test.go", + "eth_implementation": "besu", + "docker_image": "hyperledger/besu:21.0.0" + }, + { + "product": "ocr", + "test_regex": "TestOCR.*", + "file": "./smoke/ocr_test.go", + "eth_implementation": "besu", + "docker_image": "hyperledger/besu:22.0.0" + } + ] +} +``` + +## Command Line Arguments + +- ``: The name of the JSON file where the test entries will be stored. +- ``: The name of the product for which the tests are being generated. +- ``: The regular expression to match test names. +- ``: The file path where the tests are defined. +- ``: The name of the Ethereum implementation. +- ``: A comma-separated list of Docker images to be used. + +## Error Handling + +The script will panic and display error messages in the following scenarios: + +- Insufficient command line arguments. +- Empty parameters for output_file_name, product, test_regex, file, eth_implementation, or docker_images. +- Invalid Docker image format (should include a version tag). +- Invalid regular expression for test_regex. +- Errors in file operations (opening, reading, writing). + +## Detailed Steps + +1. **Argument Parsing**: The script expects at least 7 command line arguments. It splits the `` argument into a slice. +2. **Validation**: It validates the input parameters, ensuring none are empty and the regular expression compiles. +3. **File Operations**: + - If the output file exists, it reads and unmarshals the content. + - If it doesn't exist, it creates a new file. +4. **Appending Entries**: For each Docker image, it creates a new `OutputEntry` and appends it to the output. +5. **JSON Marshaling**: It marshals the updated output to JSON and writes it back to the file. +6. **Completion Message**: Prints a success message indicating the number of tests added. + +## Notes + +- Ensure the Docker image names include version tags, e.g., `hyperledger/besu:21.0.0`. +- The script appends new entries; it does not overwrite existing entries in the output file. diff --git a/tools/testlistgenerator/go.mod b/tools/testlistgenerator/go.mod new file mode 100644 index 000000000..73b21b357 --- /dev/null +++ b/tools/testlistgenerator/go.mod @@ -0,0 +1,11 @@ +module github.com/smartcontractkit/chainlink-testing-framework/tools/ecrimagefetcher + +go 1.21.7 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/testlistgenerator/go.sum b/tools/testlistgenerator/go.sum new file mode 100644 index 000000000..60ce688a0 --- /dev/null +++ b/tools/testlistgenerator/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/testlistgenerator/main.go b/tools/testlistgenerator/main.go new file mode 100644 index 000000000..370b88208 --- /dev/null +++ b/tools/testlistgenerator/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" +) + +type Input struct { + Product string `json:"product"` + TestRegex string `json:"test_regex"` + File string `json:"file"` + EthImplementation string `json:"eth_implementation"` + DockerImages []string `json:"docker_images"` +} + +type OutputEntry struct { + Product string `json:"product"` + TestRegex string `json:"test_regex"` + File string `json:"file"` + EthImplementationName string `json:"eth_implementation"` + DockerImage string `json:"docker_image"` +} + +type Output struct { + Entries []OutputEntry `json:"tests"` +} + +const ( + InsufficientArgsErr = `Usage: go run main.go ' ' +Example: go run main.go 'ocr' 'TestOCR.*' './smoke/ocr_test.go' 'besu' 'hyperledger/besu:21.0.0,hyperledger/besu:22.0.0'` + EmptyParameterErr = "parameter '%s' cannot be empty" +) + +// this script builds a JSON file with the compatibility tests to be run for a given product and Ethereum implementation +func main() { + if len(os.Args) < 7 { + panic(errors.New(InsufficientArgsErr)) + } + + outputFile := os.Args[1] + if outputFile == "" { + panic(fmt.Errorf(EmptyParameterErr, "output_file_name")) + } + dockerImagesArg := os.Args[6] + dockerImages := strings.Split(dockerImagesArg, ",") + + input := Input{ + Product: os.Args[2], + TestRegex: os.Args[3], + File: os.Args[4], + EthImplementation: os.Args[5], + DockerImages: dockerImages, + } + + validateInput(input) + + var output Output + var file *os.File + if _, err := os.Stat(outputFile); err == nil { + file, err = os.OpenFile(outputFile, os.O_RDWR, 0644) + if err != nil { + panic(fmt.Errorf("error opening file: %v", err)) + } + defer func() { _ = file.Close() }() + + bytes, err := io.ReadAll(file) + if err != nil { + panic(fmt.Errorf("error reading file: %v", err)) + } + + if len(bytes) > 0 { + if err := json.Unmarshal(bytes, &output); err != nil { + panic(fmt.Errorf("error unmarshalling JSON: %v", err)) + } + } + } else { + file, err = os.Create(outputFile) + if err != nil { + panic(fmt.Errorf("error creating file: %v", err)) + } + } + defer func() { _ = file.Close() }() + + for _, image := range dockerImages { + if !strings.Contains(image, ":") { + panic(fmt.Errorf("docker image format is invalid: %s", image)) + } + output.Entries = append(output.Entries, OutputEntry{ + Product: input.Product, + TestRegex: input.TestRegex, + File: input.File, + EthImplementationName: input.EthImplementation, + DockerImage: image, + }) + } + + newOutput, err := json.MarshalIndent(output, "", " ") + if err != nil { + panic(fmt.Errorf("error marshalling JSON: %v", err)) + } + + if _, err := file.WriteAt(newOutput, 0); err != nil { + panic(fmt.Errorf("error writing to file: %v", err)) + } + + fmt.Printf("%d compatibility test(s) for %s and %s added successfully!\n", len(dockerImages), input.Product, input.EthImplementation) +} + +func validateInput(input Input) { + if input.Product == "" { + panic(fmt.Errorf(EmptyParameterErr, "product")) + } + if input.TestRegex == "" { + panic(fmt.Errorf(EmptyParameterErr, "test_regex")) + } + + if _, err := regexp.Compile(input.TestRegex); err != nil { + panic(fmt.Errorf("failed to compile regex: %v", err)) + } + + if input.File == "" { + panic(fmt.Errorf(EmptyParameterErr, "file")) + } + if input.EthImplementation == "" { + panic(fmt.Errorf(EmptyParameterErr, "eth_implementation")) + } + if len(input.DockerImages) == 0 || (len(input.DockerImages) == 1 && input.DockerImages[0] == "") { + panic(fmt.Errorf(EmptyParameterErr, "docker_images")) + } +} diff --git a/tools/testlistgenerator/main_test.go b/tools/testlistgenerator/main_test.go new file mode 100644 index 000000000..ec5d1413e --- /dev/null +++ b/tools/testlistgenerator/main_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +const OutputFile = "test_list.json" + +func TestMainFunction(t *testing.T) { + resetEnv := func() { + os.Args = os.Args[:1] + if _, err := os.Stat(OutputFile); err == nil { + _ = os.Remove(OutputFile) + } + } + + t.Run("MissingArguments", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", "arg1", "arg2", "arg3", "arg4"} + require.PanicsWithError(t, InsufficientArgsErr, func() { main() }) + }) + + t.Run("InvalidDockerImageFormat", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu"} + require.PanicsWithError(t, fmt.Sprintf("docker image format is invalid: %s", "hyperledger/besu"), func() { main() }) + }) + + t.Run("FileCreationAndWrite", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0,hyperledger/besu:22.0.0"} + require.NotPanics(t, func() { main() }) + + require.FileExists(t, OutputFile) + bytes, err := os.ReadFile(OutputFile) + require.NoError(t, err) + + var output Output + err = json.Unmarshal(bytes, &output) + require.NoError(t, err) + require.Len(t, output.Entries, 2) + require.Equal(t, "ocr", output.Entries[0].Product) + require.Equal(t, "TestOCR.*", output.Entries[0].TestRegex) + require.Equal(t, "./smoke/ocr_test.go", output.Entries[0].File) + require.Equal(t, "besu", output.Entries[0].EthImplementationName) + require.Equal(t, "hyperledger/besu:21.0.0", output.Entries[0].DockerImage) + }) + + t.Run("AppendToFile", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0,hyperledger/besu:22.0.0"} + require.NotPanics(t, func() { main() }) + + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "geth", "ethereum/client-go:1.10.0"} + require.NotPanics(t, func() { main() }) + + require.FileExists(t, OutputFile) + bytes, err := os.ReadFile(OutputFile) + require.NoError(t, err) + + var output Output + err = json.Unmarshal(bytes, &output) + require.NoError(t, err) + require.Len(t, output.Entries, 3) + }) + + t.Run("OverwriteFile", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0,hyperledger/besu:22.0.0"} + require.NotPanics(t, func() { main() }) + + require.FileExists(t, OutputFile) + bytes, err := os.ReadFile(OutputFile) + require.NoError(t, err) + var initialOutput Output + err = json.Unmarshal(bytes, &initialOutput) + require.NoError(t, err) + require.Len(t, initialOutput.Entries, 2) + + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu:22.0.0,hyperledger/besu:23.0.0"} + require.NotPanics(t, func() { main() }) + + require.FileExists(t, OutputFile) + bytes, err = os.ReadFile(OutputFile) + require.NoError(t, err) + + var output Output + err = json.Unmarshal(bytes, &output) + require.NoError(t, err) + require.Len(t, output.Entries, 4) + require.Equal(t, "hyperledger/besu:23.0.0", output.Entries[3].DockerImage) + }) + + t.Run("EmptyOutputFileName", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", "", "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0"} + require.PanicsWithError(t, fmt.Sprintf(EmptyParameterErr, "output_file_name"), func() { main() }) + }) + + t.Run("EmptyProduct", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "", "TestOCR.*", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0"} + require.PanicsWithError(t, fmt.Sprintf(EmptyParameterErr, "product"), func() { main() }) + }) + + t.Run("EmptyTestRegex", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0"} + require.PanicsWithError(t, fmt.Sprintf(EmptyParameterErr, "test_regex"), func() { main() }) + }) + + t.Run("InvalidTestRegex", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "[invalid", "./smoke/ocr_test.go", "besu", "hyperledger/besu:21.0.0"} + require.PanicsWithError(t, fmt.Sprintf("failed to compile regex: %v", "error parsing regexp: missing closing ]: `[invalid`"), func() { main() }) + }) + + t.Run("EmptyFile", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "", "besu", "hyperledger/besu:21.0.0"} + require.PanicsWithError(t, fmt.Sprintf(EmptyParameterErr, "file"), func() { main() }) + }) + + t.Run("EmptyEthImplementation", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "", "hyperledger/besu:21.0.0"} + require.PanicsWithError(t, fmt.Sprintf(EmptyParameterErr, "eth_implementation"), func() { main() }) + }) + + t.Run("EmptyDockerImages", func(t *testing.T) { + resetEnv() + os.Args = []string{"main", OutputFile, "ocr", "TestOCR.*", "./smoke/ocr_test.go", "besu", ""} + require.PanicsWithError(t, fmt.Sprintf(EmptyParameterErr, "docker_images"), func() { main() }) + }) + + defer func() { resetEnv() }() +} diff --git a/tools/testlistgenerator/package.json b/tools/testlistgenerator/package.json new file mode 100644 index 000000000..1f0ad9c1b --- /dev/null +++ b/tools/testlistgenerator/package.json @@ -0,0 +1,5 @@ +{ + "name": "ecrimagefetcher", + "description": "Tool to generate a JSON list of tests", + "version": "1.0.0" +}