-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcmd_testing.go
More file actions
406 lines (349 loc) · 10.9 KB
/
cmd_testing.go
File metadata and controls
406 lines (349 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
package main
import (
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────
// make:test
// ──────────────────────────────────────────────
var makeTestCmd = &cobra.Command{
Use: "make:test <Name>",
Short: "Scaffold a new gest test file",
Long: bold(
"make:test",
) + ` scaffolds a new gest test file in ` + colorCyan + `internal/tests/` + colorReset + `.
The generated file follows the ` + colorCyan + `gest v2` + colorReset + ` convention: a standard
` + colorCyan + `*_test.go` + colorReset + ` file with a ` + colorCyan + `func Test<Name>(t *testing.T)` + colorReset + ` entry point
that calls ` + colorCyan + `s.Run(t)` + colorReset + ` — fully compatible with ` + colorGray + `go test` + colorReset + `.
On the first call, gest is added to the project's ` + colorCyan + `go.mod` + colorReset + ` automatically
via ` + colorGray + `go get` + colorReset + `.
` + colorGray + `Examples:` + colorReset + `
grove make:test User
grove make:test AuthService
grove make:test order_calculations`,
Args: cobra.ExactArgs(1),
RunE: runMakeTest,
}
func runMakeTest(_ *cobra.Command, args []string) error {
name := toPascalCase(args[0])
fmt.Println()
fmt.Printf(
" %sCreating test%s %s\n",
colorGray, colorReset,
bold(name),
)
fmt.Println()
if err := scaffoldTestSpec(name); err != nil {
return err
}
snake := toSnakeCase(name)
fmt.Println()
fmt.Println(nextSteps())
fmt.Printf(
" %s1.%s Write your assertions in %s\n",
colorGray, colorReset,
colorCyan+"internal/tests/"+snake+"_test.go"+colorReset,
)
fmt.Printf(
" %s2.%s Run %s to execute all tests\n",
colorGray, colorReset,
colorGreen+"grove test"+colorReset,
)
fmt.Printf(
" %s3.%s Run %s to watch and re-run on every save\n",
colorGray, colorReset,
colorGreen+"grove test -w"+colorReset,
)
fmt.Println()
return nil
}
// ──────────────────────────────────────────────
// test
// ──────────────────────────────────────────────
var (
testCoverage bool
testWatch bool
)
var testCmd = &cobra.Command{
Use: "test",
Short: "Run all gest tests in internal/tests",
Long: bold(
"test",
) + ` runs every ` + colorCyan + `*_test.go` + colorReset + ` file found in
` + colorCyan + `internal/tests/` + colorReset + ` using the ` + colorCyan + `gest` + colorReset + ` CLI for beautiful Jest-style output.
If the ` + colorCyan + `gest` + colorReset + ` CLI is not installed, grove falls back to ` + colorGray + `go test -v` + colorReset + `
automatically. Install it for the full experience:
` + colorGray + `go install github.com/caiolandgraf/gest/v2/cmd/gest@latest` + colorReset + `
Pass ` + colorGreen + `-c` + colorReset + ` to display a per-suite coverage report after the run.
Pass ` + colorGreen + `-w` + colorReset + ` to enter watch mode — tests re-run automatically on every
file change. Flags can be combined: ` + colorGreen + `-wc` + colorReset + `.
` + colorGray + `Examples:` + colorReset + `
grove test
grove test -c
grove test -w
grove test -wc`,
RunE: runTest,
}
func init() {
testCmd.Flags().BoolVarP(
&testCoverage,
"coverage", "c", false,
"Display a per-suite coverage report",
)
testCmd.Flags().BoolVarP(
&testWatch,
"watch", "w", false,
"Re-run tests automatically on file changes",
)
}
func runTest(_ *cobra.Command, _ []string) error {
const testsDir = "./internal/tests"
if _, err := os.Stat(testsDir); os.IsNotExist(err) {
return fmt.Errorf(
"tests directory not found: %s\n\n"+
" Create your first test with: %s",
colorCyan+testsDir+colorReset,
colorGreen+"grove make:test <Name>"+colorReset,
)
}
if testWatch {
return runTestWatch()
}
return runTestOnce()
}
// ──────────────────────────────────────────────
// One-shot run
// ──────────────────────────────────────────────
// runTestOnce runs the test suite once, preferring the gest CLI and falling
// back to plain `go test -v` when gest is not installed.
func runTestOnce() error {
cmd, args := buildTestCommand()
c := exec.Command(cmd, args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Stdin = os.Stdin
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
if err := c.Start(); err != nil {
return fmt.Errorf("failed to start tests: %w", err)
}
go func() {
sig := <-sigCh
if c.Process != nil {
_ = c.Process.Signal(sig)
}
}()
if err := c.Wait(); err != nil {
if isSignalError(err) {
fmt.Println()
fmt.Println(gray(" Tests stopped."))
fmt.Println()
return nil
}
// gest / go test exits with code 1 on failures — suppress the wrapper
// and let the runner's own output speak for itself.
return fmt.Errorf("one or more tests failed")
}
return nil
}
// ──────────────────────────────────────────────
// Watch mode
// ──────────────────────────────────────────────
// runTestWatch enters watch mode. When the gest CLI is available it delegates
// to `gest --watch [-c] ./internal/tests/...`. Otherwise it falls back to a
// simple polling loop that re-runs `go test -v` on every .go file change.
func runTestWatch() error {
gestPath, gestAvailable := resolveGestCLI()
if gestAvailable {
return runGestWatch(gestPath)
}
return runGoTestWatchLoop()
}
// runGestWatch delegates watch mode to the gest CLI binary.
func runGestWatch(gestPath string) error {
args := []string{"--watch"}
if testCoverage {
args = append(args, "-c")
}
args = append(args, "./internal/tests/...")
c := exec.Command(gestPath, args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Stdin = os.Stdin
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
if err := c.Start(); err != nil {
return fmt.Errorf("failed to start gest watch: %w", err)
}
go func() {
sig := <-sigCh
if c.Process != nil {
_ = c.Process.Signal(sig)
}
}()
if err := c.Wait(); err != nil {
if isSignalError(err) {
fmt.Println()
fmt.Println(gray(" Watch stopped."))
fmt.Println()
return nil
}
// test failures inside watch mode are not CLI errors
return nil
}
return nil
}
// runGoTestWatchLoop is the fallback watch implementation used when the gest
// CLI is not installed. It polls for .go file changes every 500 ms and
// re-runs `go test -v ./internal/tests/...` on each change.
func runGoTestWatchLoop() error {
fmt.Println()
fmt.Printf(
" %s WATCH %s Watching for changes — press %s to stop\n",
colorBgGreen, colorReset,
bold("Ctrl+C"),
)
fmt.Printf(
" %sTip: install the gest CLI for a better experience:%s\n",
colorGray, colorReset,
)
fmt.Printf(
" %sgo install github.com/caiolandgraf/gest/v2/cmd/gest@latest%s\n\n",
colorCyan,
colorReset,
)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)
// Run once immediately.
runGoTestOnce()
snapshots := snapshotGoFiles(".")
for {
select {
case <-sigCh:
fmt.Println()
fmt.Println(gray(" Watch stopped."))
fmt.Println()
return nil
case <-time.After(500 * time.Millisecond):
current := snapshotGoFiles(".")
if snapshotsChanged(snapshots, current) {
snapshots = current
fmt.Print("\033[2J\033[3J\033[H")
runGoTestOnce()
}
}
}
}
// runGoTestOnce executes `go test -v ./internal/tests/...` synchronously.
func runGoTestOnce() {
args := []string{"test", "-v"}
if testCoverage {
args = append(args, "-cover")
}
args = append(args, "./internal/tests/...")
c := exec.Command("go", args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
_ = c.Run()
}
// ──────────────────────────────────────────────
// Snapshot helpers (polling watcher)
// ──────────────────────────────────────────────
var watchExcludeDirs = map[string]bool{
".git": true,
".grove": true,
"vendor": true,
"node_modules": true,
}
// snapshotGoFiles returns a map of path → mtime (nanoseconds) for every .go
// file under root, skipping directories in watchExcludeDirs.
func snapshotGoFiles(root string) map[string]int64 {
snap := map[string]int64{}
_ = filepath.WalkDir(
root,
func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
if watchExcludeDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) == ".go" {
if info, e := d.Info(); e == nil {
snap[path] = info.ModTime().UnixNano()
}
}
return nil
},
)
return snap
}
// snapshotsChanged reports whether current differs from prev (new, deleted or
// modified files).
func snapshotsChanged(prev, current map[string]int64) bool {
if len(prev) != len(current) {
return true
}
for path, mtime := range current {
if prev[path] != mtime {
return true
}
}
return false
}
// ──────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────
// buildTestCommand returns the command and arguments to run the test suite
// once. Prefers the gest CLI; falls back to `go test -v`.
func buildTestCommand() (string, []string) {
gestPath, ok := resolveGestCLI()
if ok {
args := []string{}
if testCoverage {
args = append(args, "-c")
}
args = append(args, "./internal/tests/...")
return gestPath, args
}
// Fallback: plain go test
args := []string{"test", "-v"}
if testCoverage {
args = append(args, "-cover")
}
args = append(args, "./internal/tests/...")
return "go", args
}
// resolveGestCLI returns the path to the gest CLI binary and true when it is
// available on PATH.
func resolveGestCLI() (string, bool) {
path, err := exec.LookPath("gest")
if err != nil {
return "", false
}
return path, true
}
// joinArgs joins a slice of strings into a single space-separated string for
// display purposes.
func joinArgs(args []string) string {
result := ""
for i, a := range args {
if i > 0 {
result += " "
}
result += a
}
return result
}