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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Unreleased

- support the azurerm resource-per-folder layout (`internal/services/batch/resources/batch_account/{resource,r_create,data_source}.go`, and `actions/` alongside `resources/`): a change to any file in a resource folder, its shared `test_helpers_test.go` or its `migration/` package now discovers the tests in that folder, and import tracing from helper packages follows into resource folders
- API discovery lists directories at the PR's merge commit rather than the default branch, so tests are found in directories the PR adds or moves files into

## v1.3.5 (2026-10-20)

- build with Go 1.26.9 for its fixes to the standard library's HTTP and TLS code, mostly ways a misbehaving connection could stall or crash the program (CVE-2026-78667, CVE-2026-78669, CVE-2026-97031 and others); govulncheck finds tctest reaches several of them, and the binary now scans clean ([#152](https://github.com/katbyte/tctest/pull/152))
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,28 @@ When no test regex is provided, `tctest` automatically discovers tests by:

Files in `/client/`, `/parse/`, `/validate/` subdirectories and `registration.go`/`resourceids.go` are automatically skipped. Deleted files are also excluded.

### Resource Folders

The azurerm resource-per-folder layout gives each resource (or action) its own package under `resources/` (or `actions/`) in the service, and drops the resource name from the file names:

```
internal/services/batch/resources/batch_account/
resource.go r_create.go r_schema.go data_source.go list.go
resource_test.go resource_identity_gen_test.go data_source_test.go list_test.go
internal/services/managedredis/actions/managed_redis_flush_databases/
action.go action_test.go
```

Any file directly inside `<service>/resources/<folder>/` or `<service>/actions/<folder>/` belongs to that folder's resource. Since the file names no longer say which resource they belong to, the folder does:

- a change to `data_source.go`, `list.go`, `action.go` or `ephemeral.go` discovers that file's own tests (`data_source_test.go`, ...)
- a change to any other file in the folder (`resource.go`, `r_*.go`, shared models) discovers every test file in the folder
- so does a change to the folder's `test_helpers_test.go`, which holds no tests of its own, only the configs the folder's tests share, or to its `migration/` package

Helper packages such as `parse/` and `validate/` are unaffected, and both layouts can be mixed within a service. The service (and so the build triggered) is still the first directory: `batch`.

Inside a resource folder the layout decides, not `--fileregex` or the file name: azurerm's regex matches every file under `resources/`, and a migration file may be called `notification_hub_resource.go`; neither turns a `migration/` file into a resource file whose own directory is searched for tests.

### Test Output

Discovered tests are grouped by service and displayed with padded service names for alignment:
Expand Down
4 changes: 3 additions & 1 deletion cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,9 @@ func GetFlags() *FlagData {
// Set the global File classifier using the configured regex
provider.Classifier = func(file *provider.File) provider.FileType {
t := provider.DefaultClassifier(file)
if t == provider.FileTypeHelper && f.DiscoveryConfig.FileRegEx != nil && f.DiscoveryConfig.FileRegEx.MatchString(file.RelPath) {
// the regex never promotes a package nested inside a resource folder (its migration/): those stay helpers so the folder's
// tests are reached through them, and azurerm's regex is loose enough to match everything under resources/
if t == provider.FileTypeHelper && f.DiscoveryConfig.FileRegEx != nil && f.DiscoveryConfig.FileRegEx.MatchString(file.RelPath) && !provider.BelowResourceFolder(file.RelPath) {
return provider.FileTypeResource
}
return t
Expand Down
38 changes: 13 additions & 25 deletions cli/pr-ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,26 +176,7 @@ func (dc *AstDiscoveryContext) findLocalTestFiles(relativeDir string, resourcePr
continue
}

matched := false
for _, prefix := range resourcePrefixes {
if !strings.HasPrefix(pf.BaseName, prefix) {
continue
}
remainder := pf.BaseName[len(prefix):]

for _, re := range dc.Config.AccTestFileSuffixRegexes {
if re.MatchString(remainder) {
matched = true
break
}
}

if matched {
break
}
}

if matched {
if pf.IsTestFor(resourcePrefixes, dc.Config.AccTestFileSuffixRegexes) {
testFiles = append(testFiles, pf)
}
}
Expand Down Expand Up @@ -329,7 +310,7 @@ func (dc *AstDiscoveryContext) traceImportsToResourceFiles(helperFiles []provide

// if we have no symbol info, fall back to package-level matching
if len(symbols) == 0 {
if dc.Config.FileRegEx.MatchString(relPath) {
if isResourceFile(relPath) {
dir := filepath.ToSlash(filepath.Dir(relPath))
result[dir] = append(result[dir], relPath)
clog.Log.Debugf(" traced: %s imports %s (depth %d, package-level)", relPath, pkgPath, depth+1)
Expand Down Expand Up @@ -369,7 +350,7 @@ func (dc *AstDiscoveryContext) traceImportsToResourceFiles(helperFiles []provide
}

// this file uses a changed symbol
if dc.Config.FileRegEx.MatchString(relPath) {
if isResourceFile(relPath) {
// it's a resource file — add to results
dir := filepath.ToSlash(filepath.Dir(relPath))
result[dir] = append(result[dir], relPath)
Expand Down Expand Up @@ -397,6 +378,13 @@ func (dc *AstDiscoveryContext) traceImportsToResourceFiles(helperFiles []provide
return result
}

// isResourceFile returns true if the repo-relative path is a file whose sibling tests should run when it is affected: one the
// classifier calls a resource, as it matches the fileregex at the service root or lives in a resource folder. A package nested
// inside a folder (migration/) is a helper, so tracing carries on through it to the folder.
func isResourceFile(relPath string) bool {
return provider.NewFile(relPath).Type == provider.FileTypeResource
}

func (dc *AstDiscoveryContext) SortedTestFiles() []*provider.File {
files := make([]*provider.File, 0, len(dc.TestFiles))
for _, pf := range dc.TestFiles {
Expand Down Expand Up @@ -512,7 +500,7 @@ func (dc *AstDiscoveryContext) TraceHelperFiles(helperFiles []provider.File) {
if entries, err := os.ReadDir(filepath.Join(dc.RepoPath, dir)); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") {
if dc.Config.FileRegEx.MatchString(filepath.ToSlash(filepath.Join(dir, entry.Name()))) {
if isResourceFile(filepath.ToSlash(filepath.Join(dir, entry.Name()))) {
isSamePkg = true
break
}
Expand Down Expand Up @@ -560,7 +548,7 @@ func (dc *AstDiscoveryContext) TraceHelperFiles(helperFiles []provider.File) {
continue
}
relPath := filepath.ToSlash(filepath.Join(dir, entry.Name()))
if !dc.Config.FileRegEx.MatchString(relPath) {
if !isResourceFile(relPath) {
continue
}

Expand Down Expand Up @@ -742,7 +730,7 @@ func (dc *AstDiscoveryContext) TraceVendorFiles(vendorFiles []provider.File) {
}
relPath = filepath.ToSlash(relPath)

if !dc.Config.FileRegEx.MatchString(relPath) {
if !isResourceFile(relPath) {
return nil
}

Expand Down
38 changes: 8 additions & 30 deletions cli/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (ghr GithubRepo) PrTestsFromAPI(pri int, cfg DiscoveryConfig) (map[string][
}

clog.Log.Tracef("listing files...")
filesFiltered, err := ghr.GetPullRequestTestFiles(pri, cfg)
filesFiltered, err := ghr.GetPullRequestTestFiles(pri, pr.GetMergeCommitSHA(), cfg)
if err != nil {
return nil, fmt.Errorf("failed to get PR files for %s/%s/pull/%d: %w", ghr.Owner, ghr.Name, pri, err)
}
Expand Down Expand Up @@ -192,7 +192,9 @@ func (f *FlagData) CheckPrCanBuild(number int) error {

// GetPullRequestTestFiles fetches all changed files in a PR and determines the related test files.
// It classifies files based on the DiscoveryConfig and lists contents of directories containing changed resources to find related tests.
func (ghr GithubRepo) GetPullRequestTestFiles(pri int, cfg DiscoveryConfig) ([]provider.File, error) {
// Directories are listed at ref (the PR's merge commit, the same commit file contents are downloaded from) so directories
// the PR adds or moves files into are seen; an empty ref lists the default branch.
func (ghr GithubRepo) GetPullRequestTestFiles(pri int, ref string, cfg DiscoveryConfig) ([]provider.File, error) {
// track resource files that need sibling test file discovery
// key: directory path, value: list of resource prefixes (e.g. "foo")
resourcePrefixesByPackage := map[string][]string{}
Expand Down Expand Up @@ -280,44 +282,20 @@ func (ghr GithubRepo) GetPullRequestTestFiles(pri int, cfg DiscoveryConfig) ([]p
client, ctx := ghr.NewClient()
for dir, prefixes := range resourcePrefixesByPackage {
clog.Log.Debugf(" listing directory %s for related test files...", dir)
_, dirContents, _, err := client.Repositories.GetContents(ctx, ghr.Owner, ghr.Name, dir, nil)
_, dirContents, _, err := client.Repositories.GetContents(ctx, ghr.Owner, ghr.Name, dir, &github.RepositoryContentGetOptions{Ref: ref})
if err != nil {
// a directory new in this PR won't exist on the default branch; anything else
// would silently drop derived sibling test files
// a missing directory is not fatal; anything else would silently drop derived sibling test files
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound {
clog.Log.Debugf(" directory %s not found (new in this PR?), skipping sibling test discovery", dir)
clog.Log.Debugf(" directory %s not found, skipping sibling test discovery", dir)
continue
}
return nil, fmt.Errorf("failed to list directory %s for related test files: %w", dir, err)
}

for _, entry := range dirContents {
pf := provider.NewFile(path.Join(dir, entry.GetName()))
if pf.Type != provider.FileTypeTest {
continue
}

shouldInclude := false
for _, resource := range prefixes {
if !strings.HasPrefix(pf.BaseName, resource) {
continue
}

remainder := pf.BaseName[len(resource):]
for _, testSuffix := range cfg.AccTestFileSuffixRegexes {
if testSuffix.MatchString(remainder) {
shouldInclude = true
break
}
}

if shouldInclude {
break
}
}

if !shouldInclude {
if pf.Type != provider.FileTypeTest || !pf.IsTestFor(prefixes, cfg.AccTestFileSuffixRegexes) {
continue
}

Expand Down
Loading
Loading