Source file cli.ml
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
407
408
409
410
411
412
413
type t = {
stream : bool option;
format : string option;
bail : int option;
quick : bool option;
filter : string option;
exclude : string option;
failed : bool option;
list_only : bool option;
output_dir : string option;
update : bool option;
junit : string option;
seed : int option;
timeout : float option;
prop_count : int option;
color : string option;
tags : string list;
exclude_tags : string list;
}
let empty =
{
stream = None;
format = None;
bail = None;
quick = None;
filter = None;
exclude = None;
failed = None;
list_only = None;
output_dir = None;
update = None;
junit = None;
seed = None;
timeout = None;
prop_count = None;
color = None;
tags = [];
exclude_tags = [];
}
let version = "0.1.0"
let print_help prog_name =
let name = Filename.basename prog_name in
Pp.pr "@[<v>%s - windtrap test runner@,@," name;
Pp.pr "USAGE:@,";
Pp.pr " %s [OPTIONS] [PATTERN]@,@," name;
Pp.pr "COMMON OPTIONS:@,";
Pp.pr " -s, --stream Stream test output (don't capture)@,";
Pp.pr
" -v, --verbose Verbose output (alias for --format verbose)@,";
Pp.pr " -q, --quick Skip slow tests@,";
Pp.pr
" -x, --fail-fast Stop on first failure (alias for --bail 1)@,";
Pp.pr " --bail N Stop after N failures@,";
Pp.pr " --failed Rerun only tests that failed last time@,";
Pp.pr " -l, --list List tests without running@,";
Pp.pr " -u, --update Update snapshots@,";
Pp.pr " -f, --filter PATTERN Filter tests by name@,";
Pp.pr " -e, --exclude PATTERN Exclude tests by name@,";
Pp.pr
" --tag LABEL Run only tests with this label (repeatable)@,";
Pp.pr " --exclude-tag LABEL Skip tests with this label (repeatable)@,@,";
Pp.pr "OUTPUT:@,";
Pp.pr " --format FMT Output: verbose, compact, tap, junit@,";
Pp.pr " --junit PATH Write JUnit XML to file@,@,";
Pp.pr "OTHER:@,";
Pp.pr " --seed N Random seed for property tests@,";
Pp.pr " --timeout N Default timeout in seconds@,";
Pp.pr
" --prop-count N Number of property test cases (default: 100)@,";
Pp.pr
" --color MODE Color output: always, never, auto (default: \
auto)@,";
Pp.pr " -o, --output DIR Directory for test logs@,";
Pp.pr " -V, --version Show version and exit@,";
Pp.pr " -h, --help Show this help@,@,";
Pp.pr "ENVIRONMENT VARIABLES:@,";
Pp.pr " WINDTRAP_STREAM Stream output (1/true/yes)@,";
Pp.pr " WINDTRAP_FORMAT Output format (verbose/compact/tap/junit)@,";
Pp.pr " WINDTRAP_FILTER Filter pattern@,";
Pp.pr " WINDTRAP_EXCLUDE Exclude pattern@,";
Pp.pr " WINDTRAP_UPDATE Update snapshots (1/true/yes)@,";
Pp.pr " WINDTRAP_SEED Random seed for property tests@,";
Pp.pr " WINDTRAP_PROP_COUNT Number of property test cases@,";
Pp.pr " WINDTRAP_TIMEOUT Default timeout in seconds@,";
Pp.pr " WINDTRAP_COLOR Color output (always/never/auto)@,";
Pp.pr " WINDTRAP_TAG Required tags (comma-separated)@,";
Pp.pr " WINDTRAP_EXCLUDE_TAG Excluded tags (comma-separated)@,@,";
Pp.pr "SNAPSHOT ENVIRONMENT VARIABLES:@,";
Pp.pr " WINDTRAP_SNAPSHOT_DIR Centralized snapshot directory@,";
Pp.pr
" WINDTRAP_SNAPSHOT_DIFF_CONTEXT Context lines in diffs (default: 3)@,";
Pp.pr " WINDTRAP_SNAPSHOT_MAX_BYTES Truncation limit for snapshots@,";
Pp.pr
" WINDTRAP_SNAPSHOT_REPORT Report snapshot updates (1/true/yes)@,";
Pp.pr " WINDTRAP_PROJECT_ROOT Project root for path resolution@,";
Pp.pr " WINDTRAP_COLUMNS Override terminal width@,";
Pp.pr " WINDTRAP_TAIL_ERRORS Max lines shown per failure@,";
Pp.pr "@]%!"
let rec parse_args acc = function
| [] -> acc
| ("-s" | "--stream") :: rest ->
parse_args { acc with stream = Some true } rest
| ("-v" | "--verbose") :: rest ->
parse_args { acc with format = Some "verbose" } rest
| ("-q" | "--quick") :: rest -> parse_args { acc with quick = Some true } rest
| ("-x" | "--fail-fast") :: rest -> parse_args { acc with bail = Some 1 } rest
| "--bail" :: n :: rest -> (
match int_of_string_opt n with
| Some b when b > 0 -> parse_args { acc with bail = Some b } rest
| _ ->
Pp.epr "Error: --bail requires a positive integer@.";
exit 1)
| "--bail" :: [] ->
Pp.epr "Error: --bail requires an argument@.";
exit 1
| "--failed" :: rest -> parse_args { acc with failed = Some true } rest
| ("-l" | "--list") :: rest ->
parse_args { acc with list_only = Some true } rest
| ("-u" | "--update") :: rest ->
parse_args { acc with update = Some true } rest
| ("-f" | "--filter") :: pat :: rest ->
parse_args { acc with filter = Some pat } rest
| "--filter" :: [] | "-f" :: [] ->
Pp.epr "Error: --filter requires an argument@.";
exit 1
| ("-e" | "--exclude") :: pat :: rest ->
parse_args { acc with exclude = Some pat } rest
| "--exclude" :: [] | "-e" :: [] ->
Pp.epr "Error: --exclude requires an argument@.";
exit 1
| "--tag" :: label :: rest ->
parse_args { acc with tags = label :: acc.tags } rest
| "--tag" :: [] ->
Pp.epr "Error: --tag requires an argument@.";
exit 1
| "--exclude-tag" :: label :: rest ->
parse_args { acc with exclude_tags = label :: acc.exclude_tags } rest
| "--exclude-tag" :: [] ->
Pp.epr "Error: --exclude-tag requires an argument@.";
exit 1
| "--format" :: fmt :: rest -> parse_args { acc with format = Some fmt } rest
| "--format" :: [] ->
Pp.epr "Error: --format requires an argument@.";
exit 1
| "--junit" :: path :: rest -> parse_args { acc with junit = Some path } rest
| "--junit" :: [] ->
Pp.epr "Error: --junit requires an argument@.";
exit 1
| "--seed" :: n :: rest -> (
match int_of_string_opt n with
| Some seed -> parse_args { acc with seed = Some seed } rest
| None ->
Pp.epr "Error: --seed requires an integer argument@.";
exit 1)
| "--seed" :: [] ->
Pp.epr "Error: --seed requires an argument@.";
exit 1
| "--timeout" :: n :: rest -> (
match float_of_string_opt n with
| Some t when t > 0.0 -> parse_args { acc with timeout = Some t } rest
| _ ->
Pp.epr "Error: --timeout requires a positive number@.";
exit 1)
| "--timeout" :: [] ->
Pp.epr "Error: --timeout requires an argument@.";
exit 1
| "--prop-count" :: n :: rest -> (
match int_of_string_opt n with
| Some c when c > 0 -> parse_args { acc with prop_count = Some c } rest
| _ ->
Pp.epr "Error: --prop-count requires a positive integer@.";
exit 1)
| "--prop-count" :: [] ->
Pp.epr "Error: --prop-count requires an argument@.";
exit 1
| "--color" :: mode :: rest -> parse_args { acc with color = Some mode } rest
| "--color" :: [] ->
Pp.epr "Error: --color requires an argument (always, never, auto)@.";
exit 1
| ("-o" | "--output") :: dir :: rest ->
parse_args { acc with output_dir = Some dir } rest
| "--output" :: [] | "-o" :: [] ->
Pp.epr "Error: --output requires an argument@.";
exit 1
| ("-V" | "--version") :: _ ->
Pp.pr "windtrap %s@." version;
exit 0
| ("-h" | "--help") :: _ ->
print_help Sys.argv.(0);
exit 0
| "--" :: _ -> acc
| arg :: rest when String.length arg > 9 && String.sub arg 0 9 = "--filter="
->
let pat = String.sub arg 9 (String.length arg - 9) in
parse_args { acc with filter = Some pat } rest
| arg :: rest
when String.length arg > 10 && String.sub arg 0 10 = "--exclude=" ->
let pat = String.sub arg 10 (String.length arg - 10) in
parse_args { acc with exclude = Some pat } rest
| arg :: rest when String.length arg > 9 && String.sub arg 0 9 = "--format="
->
let fmt = String.sub arg 9 (String.length arg - 9) in
parse_args { acc with format = Some fmt } rest
| arg :: rest when String.length arg > 8 && String.sub arg 0 8 = "--junit=" ->
let path = String.sub arg 8 (String.length arg - 8) in
parse_args { acc with junit = Some path } rest
| arg :: rest when String.length arg > 9 && String.sub arg 0 9 = "--output="
->
let dir = String.sub arg 9 (String.length arg - 9) in
parse_args { acc with output_dir = Some dir } rest
| arg :: rest when String.length arg > 7 && String.sub arg 0 7 = "--seed="
-> (
let n = String.sub arg 7 (String.length arg - 7) in
match int_of_string_opt n with
| Some seed -> parse_args { acc with seed = Some seed } rest
| None ->
Pp.epr "Error: --seed requires an integer argument@.";
exit 1)
| arg :: rest
when String.length arg > 10 && String.sub arg 0 10 = "--timeout=" -> (
let n = String.sub arg 10 (String.length arg - 10) in
match float_of_string_opt n with
| Some t when t > 0.0 -> parse_args { acc with timeout = Some t } rest
| _ ->
Pp.epr "Error: --timeout requires a positive number@.";
exit 1)
| arg :: rest
when String.length arg > 13 && String.sub arg 0 13 = "--prop-count=" -> (
let n = String.sub arg 13 (String.length arg - 13) in
match int_of_string_opt n with
| Some c when c > 0 -> parse_args { acc with prop_count = Some c } rest
| _ ->
Pp.epr "Error: --prop-count requires a positive integer@.";
exit 1)
| arg :: rest when String.length arg > 6 && String.sub arg 0 6 = "--tag=" ->
let label = String.sub arg 6 (String.length arg - 6) in
parse_args { acc with tags = label :: acc.tags } rest
| arg :: rest when String.length arg > 7 && String.sub arg 0 7 = "--bail="
-> (
let n = String.sub arg 7 (String.length arg - 7) in
match int_of_string_opt n with
| Some b when b > 0 -> parse_args { acc with bail = Some b } rest
| _ ->
Pp.epr "Error: --bail requires a positive integer@.";
exit 1)
| arg :: rest
when String.length arg > 14 && String.sub arg 0 14 = "--exclude-tag=" ->
let label = String.sub arg 14 (String.length arg - 14) in
parse_args { acc with exclude_tags = label :: acc.exclude_tags } rest
| arg :: rest when String.length arg > 8 && String.sub arg 0 8 = "--color=" ->
let mode = String.sub arg 8 (String.length arg - 8) in
parse_args { acc with color = Some mode } rest
| arg :: _ when String.length arg > 0 && arg.[0] = '-' ->
Pp.epr "Unknown option: %s@." arg;
Pp.epr "Try --help for usage information.@.";
exit 1
| arg :: rest when acc.filter = None ->
parse_args { acc with filter = Some arg } rest
| arg :: _ ->
Pp.epr "Error: unexpected argument '%s' (filter already set to '%s')@."
arg
(Option.value ~default:"" acc.filter);
exit 1
let parse argv =
match Array.to_list argv with
| [] -> empty
| _ :: args -> parse_args empty args
let parse_format = function
| "verbose" -> Progress.Verbose
| "compact" -> Progress.Compact
| "tap" -> Progress.Tap
| "junit" -> Progress.Junit
| s ->
Pp.epr "Unknown format: %s. Use: verbose, compact, tap, junit@." s;
exit 1
let resolve prog cli env default =
prog
|> Option.fold ~none:cli ~some:Option.some
|> Option.fold ~none:(env ()) ~some:Option.some
|> Option.value ~default
let resolve_opt prog cli env =
prog
|> Option.fold ~none:cli ~some:Option.some
|> Option.fold ~none:(env ()) ~some:Option.some
let resolve_config ?quick ?bail ?fail_fast ?output_dir ?stream ?update
?snapshot_dir ?filter ?exclude ?failed ?format ?junit ?seed ?timeout
?prop_count ?tags ?exclude_tags (cli : t) =
Option.iter Env.override_color cli.color;
let quick = resolve quick cli.quick (fun () -> None) false in
let bail =
match (bail, fail_fast) with
| Some n, _ -> Some n
| None, Some true -> Some 1
| _ -> cli.bail
in
let output_dir =
resolve output_dir cli.output_dir
(fun () -> None)
(Path_ops.default_log_dir ())
in
let update = resolve update cli.update Env.update false in
let filter_pattern = resolve_opt filter cli.filter Env.filter in
let exclude_pattern = resolve_opt exclude cli.exclude Env.exclude in
let junit = resolve_opt junit cli.junit (fun () -> None) in
let stream = resolve stream cli.stream Env.stream false in
let capture = not stream in
let progress_mode =
match (format, cli.format, Env.format ()) with
| Some f, _, _ -> f
| None, Some s, _ -> parse_format s
| None, None, Some s -> parse_format s
| None, None, None ->
if Option.is_some junit then Progress.Junit else Progress.Compact
in
let snapshot_config =
let base = Snapshot.Config.default () in
let c =
Option.fold ~none:base
~some:(fun d -> Snapshot.Config.create ~root_dir:d ())
snapshot_dir
in
if update then Snapshot.Config.with_mode Snapshot.Update c else c
in
let seed = resolve_opt seed cli.seed Env.seed in
let timeout =
resolve_opt timeout cli.timeout (fun () ->
Option.bind (Env.timeout ()) (fun t -> if t > 0.0 then Some t else None))
in
let prop_count = resolve_opt prop_count cli.prop_count Env.prop_count in
let required_tags = Option.value ~default:[] tags @ cli.tags @ Env.tag () in
let dropped_tags =
Option.value ~default:[] exclude_tags
@ cli.exclude_tags @ Env.exclude_tag ()
in
let use_failed =
resolve (Option.map (fun b -> b) failed) cli.failed (fun () -> None) false
in
let failed_allowlist =
if use_failed then
let paths = Runner.read_last_failed output_dir in
if paths = [] then begin
Pp.epr "%a No previous failures recorded. Running all tests.@."
(Pp.styled `Yellow Pp.string)
"[INFO]";
None
end
else Some paths
else None
in
let base_filter =
Runner.make_filter ~quick ~filter_pattern ~exclude_pattern ~required_tags
~dropped_tags
in
let filter =
match failed_allowlist with
| None -> base_filter
| Some paths ->
fun ~path tags ->
if List.mem path paths then base_filter ~path tags else `Skip
in
{
Runner.filter;
progress_mode;
log_dir = output_dir;
capture;
snapshot_config;
bail;
junit_file = junit;
seed;
default_timeout = timeout;
prop_count;
}