cli/internal/featuredetection/feature_detection_test.go
Kynan Ware ae5e857c2e refactor(featuredetection): rename ActorIsAssignable to ApiActorsSupported
Aligns the feature detector field name with the downstream
ApiActorsSupported flag introduced in the previous commit, so the
signal has one consistent name from detection through to consumption.

Also consolidates leftover TODO tags (actorIsAssignableCleanup,
requestReviewsByLoginCleanup) under the single // TODO ApiActorsSupported
tag so there's exactly one thing to grep for.

Pure rename with no logic changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-24 21:09:04 -06:00

832 lines
21 KiB
Go

package featuredetection
import (
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures IssueFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: IssueFeatures{
ApiActorsSupported: true,
},
wantErr: false,
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: IssueFeatures{
ApiActorsSupported: true,
},
wantErr: false,
},
{
name: "GHE",
hostname: "git.my.org",
wantFeatures: IssueFeatures{
ApiActorsSupported: false,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.IssueFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestPullRequestFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures PullRequestFeatures
wantErr bool
}{
{
name: "github.com with all features",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{
"data": {
"PullRequest": {
"fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
]
},
"StatusCheckRollupContextConnection": {
"fields": [
{"name": "checkRunCount"},
{"name": "checkRunCountsByState"},
{"name": "statusContextCount"},
{"name": "statusContextCountsByState"}
]
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": [
{"name": "event"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
},
wantErr: false,
},
{
name: "github.com with no merge queue",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{
"data": {
"PullRequest": {
"fields": []
},
"StatusCheckRollupContextConnection": {
"fields": [
{"name": "checkRunCount"},
{"name": "checkRunCountsByState"},
{"name": "statusContextCount"},
{"name": "statusContextCountsByState"}
]
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": [
{"name": "event"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: false,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
},
wantErr: false,
},
{
name: "GHE with all features",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{
"data": {
"PullRequest": {
"fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
]
},
"StatusCheckRollupContextConnection": {
"fields": [
{"name": "checkRunCount"},
{"name": "checkRunCountsByState"},
{"name": "statusContextCount"},
{"name": "statusContextCountsByState"}
]
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": [
{"name": "event"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
},
wantErr: false,
},
{
name: "GHE with no features",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{
"data": {
"PullRequest": {
"fields": []
},
"StatusCheckRollupContextConnection": {
"fields": []
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": []
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: false,
CheckRunAndStatusContextCounts: false,
CheckRunEvent: false,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.PullRequestFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestRepositoryFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures RepositoryFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: RepositoryFeatures{
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
},
wantErr: false,
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: RepositoryFeatures{
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: `{"data": {}}`,
},
wantFeatures: RepositoryFeatures{
PullRequestTemplateQuery: false,
},
wantErr: false,
},
{
name: "GHE has pull request template query",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "pullRequestTemplates"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
PullRequestTemplateQuery: true,
},
wantErr: false,
},
{
name: "GHE has visibility field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "visibility"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
VisibilityField: true,
},
wantErr: false,
},
{
name: "GHE has automerge field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "autoMergeAllowed"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
AutoMerge: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.RepositoryFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestProjectV1Support(t *testing.T) {
tests := []struct {
name string
hostname string
httpStubs func(*httpmock.Registry)
wantFeatures gh.ProjectsV1Support
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: gh.ProjectsV1Unsupported,
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: gh.ProjectsV1Unsupported,
},
{
name: "GHE 3.16.0",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.16.0"}`),
)
},
wantFeatures: gh.ProjectsV1Supported,
},
{
name: "GHE 3.16.1",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.16.1"}`),
)
},
wantFeatures: gh.ProjectsV1Supported,
},
{
name: "GHE 3.17",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.17.0"}`),
)
},
wantFeatures: gh.ProjectsV1Unsupported,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
detector := NewDetector(httpClient, tt.hostname)
require.Equal(t, tt.wantFeatures, detector.ProjectsV1())
})
}
}
func TestAdvancedIssueSearchSupport(t *testing.T) {
withIssueAdvanced := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"ISSUE_ADVANCED"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}`
withoutIssueAdvanced := `{"data":{"SearchType":{"enumValues":[{"name":"ISSUE"},{"name":"REPOSITORY"},{"name":"USER"},{"name":"DISCUSSION"}]}}}`
tests := []struct {
name string
hostname string
httpStubs func(*httpmock.Registry)
wantFeatures SearchFeatures
}{
{
name: "github.com, before ISSUE_ADVANCED cleanup",
hostname: "github.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
},
{
name: "github.com, after ISSUE_ADVANCED cleanup",
hostname: "github.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withoutIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
},
{
name: "ghec data residency (ghe.com), before ISSUE_ADVANCED cleanup",
hostname: "stampname.ghe.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
},
{
name: "ghec data residency (ghe.com), after ISSUE_ADVANCED cleanup",
hostname: "stampname.ghe.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withoutIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
},
{
name: "GHE 3.18, before ISSUE_ADVANCED cleanup",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.18.0"}`),
)
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
},
{
name: "GHE 3.18, after ISSUE_ADVANCED cleanup",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.18.0"}`),
)
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withoutIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
},
{
name: "GHE >3.18, before ISSUE_ADVANCED cleanup",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.18.1"}`),
)
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOptIn,
},
{
name: "GHE >3.18, after ISSUE_ADVANCED cleanup",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.18.1"}`),
)
reg.Register(
httpmock.GraphQL(`query SearchType_enumValues\b`),
httpmock.StringResponse(withoutIssueAdvanced),
)
},
wantFeatures: advancedIssueSearchSupportedAsOnlyBackend,
},
{
name: "GHE <3.18 (no advanced issue search support)",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.17.999"}`),
)
},
wantFeatures: advancedIssueSearchNotSupported,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
detector := NewDetector(httpClient, tt.hostname)
features, err := detector.SearchFeatures()
require.NoError(t, err)
require.Equal(t, tt.wantFeatures, features)
})
}
}
func TestProjectFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures ProjectFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: ProjectFeatures{
ProjectItemQuery: true,
},
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: ProjectFeatures{
ProjectItemQuery: true,
},
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query ProjectV2_fields\b`: `{"data": {}}`,
},
wantFeatures: ProjectFeatures{},
},
{
name: "GHE items field without query arg",
hostname: "git.my.org",
queryResponse: map[string]string{
`query ProjectV2_fields\b`: heredoc.Doc(`
{ "data": { "ProjectV2": { "fields": [
{"name": "items", "args": [
{"name": "after"},
{"name": "first"}
]}
] } } }
`),
},
wantFeatures: ProjectFeatures{},
},
{
name: "GHE items field with query arg",
hostname: "git.my.org",
queryResponse: map[string]string{
`query ProjectV2_fields\b`: heredoc.Doc(`
{ "data": { "ProjectV2": { "fields": [
{"name": "items", "args": [
{"name": "after"},
{"name": "first"},
{"name": "query"}
]}
] } } }
`),
},
wantFeatures: ProjectFeatures{
ProjectItemQuery: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.ProjectFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestReleaseFeatures(t *testing.T) {
withImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"},{"name":"immutable"}]}}}`
withoutImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"}]}}}`
tests := []struct {
name string
hostname string
httpStubs func(*httpmock.Registry)
wantFeatures ReleaseFeatures
}{
{
// This is not a real case as `github.com` supports immutable releases.
name: "github.com, immutable releases unsupported",
hostname: "github.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query Release_fields\b`),
httpmock.StringResponse(withoutImmutableReleaseSupport),
)
},
wantFeatures: ReleaseFeatures{
ImmutableReleases: false,
},
},
{
name: "github.com, immutable releases supported",
hostname: "github.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query Release_fields\b`),
httpmock.StringResponse(withImmutableReleaseSupport),
)
},
wantFeatures: ReleaseFeatures{
ImmutableReleases: true,
},
},
{
// This is not a real case as `github.com` supports immutable releases.
name: "ghec data residency (ghe.com), immutable releases unsupported",
hostname: "stampname.ghe.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query Release_fields\b`),
httpmock.StringResponse(withoutImmutableReleaseSupport),
)
},
wantFeatures: ReleaseFeatures{
ImmutableReleases: false,
},
},
{
name: "ghec data residency (ghe.com), immutable releases supported",
hostname: "stampname.ghe.com",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query Release_fields\b`),
httpmock.StringResponse(withImmutableReleaseSupport),
)
},
wantFeatures: ReleaseFeatures{
ImmutableReleases: true,
},
},
{
name: "GHE, immutable releases unsupported",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query Release_fields\b`),
httpmock.StringResponse(withoutImmutableReleaseSupport),
)
},
wantFeatures: ReleaseFeatures{
ImmutableReleases: false,
},
},
{
name: "GHE, immutable releases supported",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query Release_fields\b`),
httpmock.StringResponse(withImmutableReleaseSupport),
)
},
wantFeatures: ReleaseFeatures{
ImmutableReleases: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
detector := NewDetector(httpClient, tt.hostname)
features, err := detector.ReleaseFeatures()
require.NoError(t, err)
require.Equal(t, tt.wantFeatures, features)
})
}
}
func TestActionsFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
httpStubs func(*httpmock.Registry)
wantFeatures ActionsFeatures
}{
{
name: "github.com, workflow dispatch run details supported",
hostname: "github.com",
wantFeatures: ActionsFeatures{
DispatchRunDetails: true,
},
},
{
name: "ghec data residency (ghe.com), workflow dispatch run details supported",
hostname: "stampname.ghe.com",
wantFeatures: ActionsFeatures{
DispatchRunDetails: true,
},
},
{
name: "GHE 3.20, workflow dispatch run details not supported",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.20.999"}`),
)
},
wantFeatures: ActionsFeatures{
DispatchRunDetails: false,
},
},
{
name: "GHE 3.21, workflow dispatch run details supported",
hostname: "git.my.org",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "api/v3/meta"),
httpmock.StringResponse(`{"installed_version":"3.21.0"}`),
)
},
wantFeatures: ActionsFeatures{
DispatchRunDetails: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
detector := NewDetector(httpClient, tt.hostname)
features, err := detector.ActionsFeatures()
require.NoError(t, err)
require.Equal(t, tt.wantFeatures, features)
})
}
}