+4
-4
ROADMAP.md
+4
-4
ROADMAP.md
···
108
- [x] `release --dry-run`
109
- [x] Show what would be written without writing
110
- [x] Display preview of CHANGELOG changes with styled output
111
-
- [ ] Non-TTY environment handling
112
-
- [ ] Detect TTY availability
113
-
- [ ] Fallback to non-interactive mode
114
-
- [ ] CI-friendly error messages
115
- [ ] Add pre-commit hook examples
116
- [ ] Validate commit message format
117
- [ ] Ensure `.changes/` entries exist for features
···
108
- [x] `release --dry-run`
109
- [x] Show what would be written without writing
110
- [x] Display preview of CHANGELOG changes with styled output
111
+
- [x] Non-TTY environment handling
112
+
- [x] Detect TTY availability
113
+
- [x] Fallback to non-interactive mode
114
+
- [x] CI-friendly error messages
115
- [ ] Add pre-commit hook examples
116
- [ ] Validate commit message format
117
- [ ] Ensure `.changes/` entries exist for features
+44
cmd/diff.go
+44
cmd/diff.go
···
34
"github.com/spf13/cobra"
35
"github.com/stormlightlabs/git-storm/internal/diff"
36
"github.com/stormlightlabs/git-storm/internal/gitlog"
37
"github.com/stormlightlabs/git-storm/internal/ui"
38
)
39
···
124
})
125
}
126
127
model := ui.NewMultiFileDiffModel(allDiffs, expanded, view)
128
129
p := tea.NewProgram(model, tea.WithAltScreen())
···
144
return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName)
145
}
146
}
···
34
"github.com/spf13/cobra"
35
"github.com/stormlightlabs/git-storm/internal/diff"
36
"github.com/stormlightlabs/git-storm/internal/gitlog"
37
+
"github.com/stormlightlabs/git-storm/internal/tty"
38
"github.com/stormlightlabs/git-storm/internal/ui"
39
)
40
···
125
})
126
}
127
128
+
if !tty.IsInteractive() {
129
+
return outputPlainDiff(allDiffs, expanded, view)
130
+
}
131
+
132
model := ui.NewMultiFileDiffModel(allDiffs, expanded, view)
133
134
p := tea.NewProgram(model, tea.WithAltScreen())
···
149
return 0, fmt.Errorf("invalid view %q: expected one of split, unified", viewName)
150
}
151
}
152
+
153
+
// outputPlainDiff outputs diffs in plain text format for non-interactive environments.
154
+
//
155
+
// TODO: move this to package [diff]
156
+
func outputPlainDiff(allDiffs []ui.FileDiff, expanded bool, view diff.DiffViewKind) error {
157
+
for i, fileDiff := range allDiffs {
158
+
fmt.Printf("=== File %d/%d ===\n", i+1, len(allDiffs))
159
+
fmt.Printf("--- %s\n", fileDiff.OldPath)
160
+
fmt.Printf("+++ %s\n", fileDiff.NewPath)
161
+
fmt.Println()
162
+
163
+
var formatter diff.Formatter
164
+
switch view {
165
+
case diff.ViewUnified:
166
+
formatter = &diff.UnifiedFormatter{
167
+
TerminalWidth: 80,
168
+
ShowLineNumbers: true,
169
+
Expanded: expanded,
170
+
EnableWordWrap: false,
171
+
}
172
+
default:
173
+
formatter = &diff.SideBySideFormatter{
174
+
TerminalWidth: 80,
175
+
ShowLineNumbers: true,
176
+
Expanded: expanded,
177
+
EnableWordWrap: false,
178
+
}
179
+
}
180
+
181
+
output := formatter.Format(fileDiff.Edits)
182
+
fmt.Println(output)
183
+
184
+
if i < len(allDiffs)-1 {
185
+
fmt.Println()
186
+
}
187
+
}
188
+
189
+
return nil
190
+
}
+5
-1
cmd/generate.go
+5
-1
cmd/generate.go
···
22
"github.com/stormlightlabs/git-storm/internal/changeset"
23
"github.com/stormlightlabs/git-storm/internal/gitlog"
24
"github.com/stormlightlabs/git-storm/internal/style"
25
"github.com/stormlightlabs/git-storm/internal/ui"
26
)
27
···
74
interactive review mode.`,
75
Args: cobra.MaximumNArgs(2),
76
RunE: func(cmd *cobra.Command, args []string) error {
77
var from, to string
78
79
if sinceTag != "" {
···
246
247
c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI")
248
c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag")
249
-
250
return c
251
}
···
22
"github.com/stormlightlabs/git-storm/internal/changeset"
23
"github.com/stormlightlabs/git-storm/internal/gitlog"
24
"github.com/stormlightlabs/git-storm/internal/style"
25
+
"github.com/stormlightlabs/git-storm/internal/tty"
26
"github.com/stormlightlabs/git-storm/internal/ui"
27
)
28
···
75
interactive review mode.`,
76
Args: cobra.MaximumNArgs(2),
77
RunE: func(cmd *cobra.Command, args []string) error {
78
+
if interactive && !tty.IsInteractive() {
79
+
return tty.ErrorInteractiveFlag("--interactive")
80
+
}
81
+
82
var from, to string
83
84
if sinceTag != "" {
···
251
252
c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI")
253
c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag")
254
return c
255
}
+8
cmd/unreleased.go
+8
cmd/unreleased.go
···
65
"github.com/stormlightlabs/git-storm/internal/changeset"
66
"github.com/stormlightlabs/git-storm/internal/gitlog"
67
"github.com/stormlightlabs/git-storm/internal/style"
68
"github.com/stormlightlabs/git-storm/internal/ui"
69
)
70
···
149
Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize
150
unreleased entries before final release.`,
151
RunE: func(cmd *cobra.Command, args []string) error {
152
entries, err := changeset.List(changesDir)
153
if err != nil {
154
return fmt.Errorf("failed to list changelog entries: %w", err)
···
65
"github.com/stormlightlabs/git-storm/internal/changeset"
66
"github.com/stormlightlabs/git-storm/internal/gitlog"
67
"github.com/stormlightlabs/git-storm/internal/style"
68
+
"github.com/stormlightlabs/git-storm/internal/tty"
69
"github.com/stormlightlabs/git-storm/internal/ui"
70
)
71
···
150
Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize
151
unreleased entries before final release.`,
152
RunE: func(cmd *cobra.Command, args []string) error {
153
+
if !tty.IsInteractive() {
154
+
return tty.ErrorInteractiveRequired("storm unreleased review", []string{
155
+
"Use 'storm unreleased list' to view entries in plain text",
156
+
"Use 'storm unreleased list --json' for JSON output",
157
+
})
158
+
}
159
+
160
entries, err := changeset.List(changesDir)
161
if err != nil {
162
return fmt.Errorf("failed to list changelog entries: %w", err)
+4
-1
go.mod
+4
-1
go.mod
+4
internal/diff/format.go
+4
internal/diff/format.go
···
32
compressedIndicator = "⋮"
33
)
34
35
+
type Formatter interface {
36
+
Format(edits []Edit) string
37
+
}
38
+
39
// SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting.
40
type SideBySideFormatter struct {
41
// TerminalWidth is the total available width for rendering
+106
-5
internal/docs/README.md
+106
-5
internal/docs/README.md
···
1
---
2
title: Testing Workflow
3
updated: 2025-11-08
4
-
version: 2
5
---
6
7
"Ride the lightning."
8
9
This document provides a comprehensive testing workflow for the `storm` changelog manager.
10
-
All tests should be run within this repository to validate functionality against real Git history.
11
12
## Setup
13
···
16
task build
17
```
18
19
## Core Workflow
20
21
### Manual Entry Creation (`unreleased add`)
···
195
- Allows selection/deselection
196
- Creates only selected entries
197
- Handles cancellation (Ctrl+C)
198
199
#### Since tag
200
···
323
324
- Empty changes directory (should show message, not crash)
325
- Corrupted entry file (should handle gracefully)
326
-
- Non-TTY environment (should detect and warn)
327
- Cancel review (Esc/q) - no changes applied
328
- Delete file that no longer exists (should error gracefully)
329
- Edit with empty fields (fields preserve original if empty)
···
463
464
**Expected:**
465
466
-
- Shows unified diff with syntax highlighting
467
-
- Iceberg theme colors
468
- Context lines displayed
469
- File headers shown
470
···
484
- No changes between refs
485
- Binary files (should indicate)
486
- Large diffs (should handle gracefully)
···
1
---
2
title: Testing Workflow
3
updated: 2025-11-08
4
+
version: 3
5
---
6
7
"Ride the lightning."
8
9
This document provides a comprehensive testing workflow for the `storm` changelog manager.
10
+
All tests should be run within this repository to validate functionality against real Git
11
+
history.
12
13
## Setup
14
···
17
task build
18
```
19
20
+
## Non-TTY Environment Handling
21
+
22
+
Storm automatically detects whether it's running in an interactive terminal (TTY) or a
23
+
non-interactive environment (CI pipelines, scripts, pipes). Commands gracefully degrade
24
+
or provide helpful error messages.
25
+
26
+
### TTY Detection
27
+
28
+
The CLI checks for:
29
+
30
+
- Terminal availability on stdin/stdout
31
+
- Common CI environment variables (GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, etc.)
32
+
33
+
### Command Behavior
34
+
35
+
#### `generate --interactive`
36
+
37
+
**Interactive (TTY):** Launches TUI for commit selection
38
+
**Non-Interactive:** Returns error with suggestion to use non-interactive mode
39
+
40
+
```bash
41
+
# CI/Non-TTY
42
+
storm generate HEAD~5 HEAD --interactive
43
+
# Error: flag '--interactive' requires an interactive terminal (detected GitHub Actions environment)
44
+
```
45
+
46
+
**Workaround:**
47
+
48
+
```bash
49
+
# Use without --interactive flag for automatic processing
50
+
storm generate HEAD~5 HEAD
51
+
```
52
+
53
+
#### `unreleased review`
54
+
55
+
**Interactive (TTY):** Launches TUI for reviewing entries
56
+
**Non-Interactive:** Returns error with alternatives
57
+
58
+
```bash
59
+
# CI/Non-TTY
60
+
storm unreleased review
61
+
# Error: command 'storm unreleased review' requires an interactive terminal (detected CI environment)
62
+
#
63
+
# Alternatives:
64
+
# - Use 'storm unreleased list' to view entries in plain text
65
+
# - Use 'storm unreleased list --json' for JSON output
66
+
```
67
+
68
+
#### `diff`
69
+
70
+
**Interactive (TTY):** Launches TUI for navigating diffs
71
+
**Non-Interactive:** Outputs plain text diff to stdout
72
+
73
+
```bash
74
+
# CI/Non-TTY - automatically outputs plain text
75
+
storm diff HEAD~1 HEAD
76
+
# === File 1/3 ===
77
+
# --- HEAD~1:file.go
78
+
# +++ HEAD:file.go
79
+
# [plain text diff output]
80
+
```
81
+
82
+
### Testing Non-TTY Behavior
83
+
84
+
#### Simulate CI environment
85
+
86
+
```bash
87
+
CI=true storm unreleased review
88
+
# Should error with CI-friendly message
89
+
```
90
+
91
+
#### Pipe output
92
+
93
+
```bash
94
+
storm diff HEAD~1 HEAD | less
95
+
# Should output plain text diff (not TUI)
96
+
```
97
+
98
+
#### Redirect to file
99
+
100
+
```bash
101
+
storm diff HEAD~1 HEAD > changes.diff
102
+
# Should write plain text to file
103
+
```
104
+
105
+
**Expected Behaviors:**
106
+
107
+
- Clear error messages indicating TTY requirement
108
+
- Suggestions for alternative commands
109
+
- CI system name detection (e.g., "detected GitHub Actions environment")
110
+
- Automatic fallback to plain text for `diff` command
111
+
- No ANSI escape codes in piped/redirected output
112
+
113
## Core Workflow
114
115
### Manual Entry Creation (`unreleased add`)
···
289
- Allows selection/deselection
290
- Creates only selected entries
291
- Handles cancellation (Ctrl+C)
292
+
- Errors gracefully in non-TTY with helpful message
293
294
#### Since tag
295
···
418
419
- Empty changes directory (should show message, not crash)
420
- Corrupted entry file (should handle gracefully)
421
+
- Non-TTY environment (detects and errors with alternatives)
422
+
- CI environment (detects CI system name in error message)
423
- Cancel review (Esc/q) - no changes applied
424
- Delete file that no longer exists (should error gracefully)
425
- Edit with empty fields (fields preserve original if empty)
···
559
560
**Expected:**
561
562
+
- TTY: Launches interactive TUI with navigation
563
+
- Non-TTY: Outputs plain text diff to stdout
564
+
- Shows diff with syntax highlighting (TTY only)
565
+
- Iceberg theme colors (TTY only)
566
- Context lines displayed
567
- File headers shown
568
···
582
- No changes between refs
583
- Binary files (should indicate)
584
- Large diffs (should handle gracefully)
585
+
- Non-TTY environment (automatic plain text output)
586
+
- Piped output (plain text format)
587
+
- Redirected to file (plain text format)
+110
internal/tty/tty.go
+110
internal/tty/tty.go
···
···
1
+
// package tty provides utilities for detecting terminal (TTY) availability and
2
+
// generating appropriate fallback behavior for non-interactive environments.
3
+
package tty
4
+
5
+
import (
6
+
"errors"
7
+
"fmt"
8
+
"os"
9
+
10
+
"golang.org/x/term"
11
+
)
12
+
13
+
// IsTTY checks if the given file descriptor is a terminal.
14
+
func IsTTY(fd uintptr) bool {
15
+
return term.IsTerminal(int(fd))
16
+
}
17
+
18
+
// IsInteractive checks if both stdin and stdout are connected to a terminal.
19
+
// This is the primary check for determining if TUI applications can run.
20
+
func IsInteractive() bool {
21
+
return IsTTY(os.Stdin.Fd()) && IsTTY(os.Stdout.Fd())
22
+
}
23
+
24
+
// IsCI detects if the current environment is a CI system by checking for common
25
+
// CI environment variables.
26
+
func IsCI() bool {
27
+
ciEnvVars := []string{
28
+
"CI", // Generic CI indicator
29
+
"CONTINUOUS_INTEGRATION",
30
+
"GITHUB_ACTIONS",
31
+
"GITLAB_CI",
32
+
"CIRCLECI",
33
+
"TRAVIS",
34
+
"JENKINS_URL",
35
+
"BUILDKITE",
36
+
"DRONE",
37
+
"TEAMCITY_VERSION",
38
+
}
39
+
40
+
for _, envVar := range ciEnvVars {
41
+
if os.Getenv(envVar) != "" {
42
+
return true
43
+
}
44
+
}
45
+
46
+
return false
47
+
}
48
+
49
+
// GetCIName attempts to identify the specific CI system being used.
50
+
func GetCIName() string {
51
+
ciMap := map[string]string{
52
+
"GITHUB_ACTIONS": "GitHub Actions",
53
+
"GITLAB_CI": "GitLab CI",
54
+
"CIRCLECI": "CircleCI",
55
+
"TRAVIS": "Travis CI",
56
+
"JENKINS_URL": "Jenkins",
57
+
"BUILDKITE": "Buildkite",
58
+
"DRONE": "Drone CI",
59
+
"TEAMCITY_VERSION": "TeamCity",
60
+
}
61
+
62
+
for envVar, name := range ciMap {
63
+
if os.Getenv(envVar) != "" {
64
+
return name
65
+
}
66
+
}
67
+
68
+
if IsCI() {
69
+
return "CI"
70
+
}
71
+
72
+
return ""
73
+
}
74
+
75
+
// ErrorInteractiveRequired returns a formatted error message indicating that the
76
+
// command requires an interactive terminal, with suggestions for alternatives.
77
+
func ErrorInteractiveRequired(commandName string, alternatives []string) error {
78
+
msg := fmt.Sprintf("command '%s' requires an interactive terminal", commandName)
79
+
80
+
if IsCI() {
81
+
ciName := GetCIName()
82
+
msg += fmt.Sprintf(" (detected %s environment)", ciName)
83
+
} else {
84
+
msg += " (stdin is not a TTY)"
85
+
}
86
+
87
+
if len(alternatives) > 0 {
88
+
msg += "\n\nAlternatives:"
89
+
for _, alt := range alternatives {
90
+
msg += fmt.Sprintf("\n - %s", alt)
91
+
}
92
+
}
93
+
94
+
return errors.New(msg)
95
+
}
96
+
97
+
// ErrorInteractiveFlag returns a formatted error message indicating that an
98
+
// interactive flag cannot be used in a non-TTY environment.
99
+
func ErrorInteractiveFlag(flagName string) error {
100
+
msg := fmt.Sprintf("flag '%s' requires an interactive terminal", flagName)
101
+
102
+
if IsCI() {
103
+
ciName := GetCIName()
104
+
msg += fmt.Sprintf(" (detected %s environment)", ciName)
105
+
} else {
106
+
msg += " (stdin is not a TTY)"
107
+
}
108
+
109
+
return errors.New(msg)
110
+
}
+288
internal/tty/tty_test.go
+288
internal/tty/tty_test.go
···
···
1
+
package tty
2
+
3
+
import (
4
+
"os"
5
+
"strings"
6
+
"testing"
7
+
)
8
+
9
+
func TestIsCI(t *testing.T) {
10
+
tests := []struct {
11
+
name string
12
+
envVars map[string]string
13
+
expected bool
14
+
}{
15
+
{
16
+
name: "no CI vars",
17
+
envVars: map[string]string{},
18
+
expected: false,
19
+
},
20
+
{
21
+
name: "generic CI var",
22
+
envVars: map[string]string{"CI": "true"},
23
+
expected: true,
24
+
},
25
+
{
26
+
name: "GitHub Actions",
27
+
envVars: map[string]string{"GITHUB_ACTIONS": "true"},
28
+
expected: true,
29
+
},
30
+
{
31
+
name: "GitLab CI",
32
+
envVars: map[string]string{"GITLAB_CI": "true"},
33
+
expected: true,
34
+
},
35
+
{
36
+
name: "CircleCI",
37
+
envVars: map[string]string{"CIRCLECI": "true"},
38
+
expected: true,
39
+
},
40
+
{
41
+
name: "multiple CI vars",
42
+
envVars: map[string]string{"CI": "true", "TRAVIS": "true"},
43
+
expected: true,
44
+
},
45
+
}
46
+
47
+
for _, tt := range tests {
48
+
t.Run(tt.name, func(t *testing.T) {
49
+
ciEnvVars := []string{
50
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
51
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
52
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
53
+
}
54
+
for _, v := range ciEnvVars {
55
+
os.Unsetenv(v)
56
+
}
57
+
58
+
for k, v := range tt.envVars {
59
+
os.Setenv(k, v)
60
+
}
61
+
62
+
defer func() {
63
+
for k := range tt.envVars {
64
+
os.Unsetenv(k)
65
+
}
66
+
}()
67
+
68
+
result := IsCI()
69
+
if result != tt.expected {
70
+
t.Errorf("IsCI() = %v, expected %v", result, tt.expected)
71
+
}
72
+
})
73
+
}
74
+
}
75
+
76
+
func TestGetCIName(t *testing.T) {
77
+
tests := []struct {
78
+
name string
79
+
envVar string
80
+
expected string
81
+
}{
82
+
{
83
+
name: "GitHub Actions",
84
+
envVar: "GITHUB_ACTIONS",
85
+
expected: "GitHub Actions",
86
+
},
87
+
{
88
+
name: "GitLab CI",
89
+
envVar: "GITLAB_CI",
90
+
expected: "GitLab CI",
91
+
},
92
+
{
93
+
name: "CircleCI",
94
+
envVar: "CIRCLECI",
95
+
expected: "CircleCI",
96
+
},
97
+
{
98
+
name: "Travis CI",
99
+
envVar: "TRAVIS",
100
+
expected: "Travis CI",
101
+
},
102
+
{
103
+
name: "Jenkins",
104
+
envVar: "JENKINS_URL",
105
+
expected: "Jenkins",
106
+
},
107
+
{
108
+
name: "Buildkite",
109
+
envVar: "BUILDKITE",
110
+
expected: "Buildkite",
111
+
},
112
+
{
113
+
name: "Drone CI",
114
+
envVar: "DRONE",
115
+
expected: "Drone CI",
116
+
},
117
+
{
118
+
name: "TeamCity",
119
+
envVar: "TEAMCITY_VERSION",
120
+
expected: "TeamCity",
121
+
},
122
+
{
123
+
name: "Generic CI",
124
+
envVar: "CI",
125
+
expected: "CI",
126
+
},
127
+
{
128
+
name: "No CI",
129
+
envVar: "",
130
+
expected: "",
131
+
},
132
+
}
133
+
134
+
for _, tt := range tests {
135
+
t.Run(tt.name, func(t *testing.T) {
136
+
ciEnvVars := []string{
137
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
138
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
139
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
140
+
}
141
+
for _, v := range ciEnvVars {
142
+
os.Unsetenv(v)
143
+
}
144
+
145
+
if tt.envVar != "" {
146
+
os.Setenv(tt.envVar, "true")
147
+
}
148
+
149
+
defer func() {
150
+
if tt.envVar != "" {
151
+
os.Unsetenv(tt.envVar)
152
+
}
153
+
}()
154
+
155
+
result := GetCIName()
156
+
if result != tt.expected {
157
+
t.Errorf("GetCIName() = %q, expected %q", result, tt.expected)
158
+
}
159
+
})
160
+
}
161
+
}
162
+
163
+
func TestErrorInteractiveRequired(t *testing.T) {
164
+
tests := []struct {
165
+
name string
166
+
commandName string
167
+
alternatives []string
168
+
ciEnv string
169
+
wantContains []string
170
+
}{
171
+
{
172
+
name: "basic error",
173
+
commandName: "review",
174
+
wantContains: []string{
175
+
"command 'review' requires an interactive terminal",
176
+
},
177
+
},
178
+
{
179
+
name: "with alternatives",
180
+
commandName: "review",
181
+
alternatives: []string{
182
+
"Use 'storm unreleased list' to view entries",
183
+
"Use 'storm unreleased list --json' for JSON output",
184
+
},
185
+
wantContains: []string{
186
+
"command 'review' requires an interactive terminal",
187
+
"Alternatives:",
188
+
"Use 'storm unreleased list' to view entries",
189
+
"Use 'storm unreleased list --json' for JSON output",
190
+
},
191
+
},
192
+
{
193
+
name: "CI environment",
194
+
commandName: "diff",
195
+
ciEnv: "GITHUB_ACTIONS",
196
+
wantContains: []string{
197
+
"command 'diff' requires an interactive terminal",
198
+
"detected GitHub Actions environment",
199
+
},
200
+
},
201
+
}
202
+
203
+
for _, tt := range tests {
204
+
t.Run(tt.name, func(t *testing.T) {
205
+
ciEnvVars := []string{
206
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
207
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
208
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
209
+
}
210
+
for _, v := range ciEnvVars {
211
+
os.Unsetenv(v)
212
+
}
213
+
214
+
if tt.ciEnv != "" {
215
+
os.Setenv(tt.ciEnv, "true")
216
+
defer os.Unsetenv(tt.ciEnv)
217
+
}
218
+
219
+
err := ErrorInteractiveRequired(tt.commandName, tt.alternatives)
220
+
if err == nil {
221
+
t.Fatal("ErrorInteractiveRequired() returned nil, expected error")
222
+
}
223
+
224
+
errMsg := err.Error()
225
+
for _, want := range tt.wantContains {
226
+
if !strings.Contains(errMsg, want) {
227
+
t.Errorf("ErrorInteractiveRequired() error message missing %q\nGot: %s", want, errMsg)
228
+
}
229
+
}
230
+
})
231
+
}
232
+
}
233
+
234
+
func TestErrorInteractiveFlag(t *testing.T) {
235
+
tests := []struct {
236
+
name string
237
+
flagName string
238
+
ciEnv string
239
+
wantContains []string
240
+
}{
241
+
{
242
+
name: "basic error",
243
+
flagName: "--interactive",
244
+
wantContains: []string{
245
+
"flag '--interactive' requires an interactive terminal",
246
+
},
247
+
},
248
+
{
249
+
name: "CI environment",
250
+
flagName: "--interactive",
251
+
ciEnv: "GITLAB_CI",
252
+
wantContains: []string{
253
+
"flag '--interactive' requires an interactive terminal",
254
+
"detected GitLab CI environment",
255
+
},
256
+
},
257
+
}
258
+
259
+
for _, tt := range tests {
260
+
t.Run(tt.name, func(t *testing.T) {
261
+
ciEnvVars := []string{
262
+
"CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS",
263
+
"GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL",
264
+
"BUILDKITE", "DRONE", "TEAMCITY_VERSION",
265
+
}
266
+
for _, v := range ciEnvVars {
267
+
os.Unsetenv(v)
268
+
}
269
+
270
+
if tt.ciEnv != "" {
271
+
os.Setenv(tt.ciEnv, "true")
272
+
defer os.Unsetenv(tt.ciEnv)
273
+
}
274
+
275
+
err := ErrorInteractiveFlag(tt.flagName)
276
+
if err == nil {
277
+
t.Fatal("ErrorInteractiveFlag() returned nil, expected error")
278
+
}
279
+
280
+
errMsg := err.Error()
281
+
for _, want := range tt.wantContains {
282
+
if !strings.Contains(errMsg, want) {
283
+
t.Errorf("ErrorInteractiveFlag() error message missing %q\nGot: %s", want, errMsg)
284
+
}
285
+
}
286
+
})
287
+
}
288
+
}