-
Notifications
You must be signed in to change notification settings - Fork 1
/
testrunner.nim
388 lines (326 loc) · 10.5 KB
/
testrunner.nim
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
#
## Nim test runner
#
# Copyright 2017 Federico Ceratto <federico.ceratto@gmail.com>
# Released under GPLv3 License, see LICENSE file
import
os,
parsecfg,
parseopt,
streams,
strutils,
terminal
from osproc import execCmd
from times import epochTime
from globbing import match_filename
when not defined(nofswatch):
import times
import fswatch
when defined(linux):
import libnotify
import junit
const
default_conf_fname = ".testrunner.conf"
default_test_fn_matchers = @["test_*.nim", "test/*.nim", "tests/*.nim"]
default_monitor_fn_matcher = "**/*.nim"
symbol = "●"
success_color = fgGreen
fail_color = fgRed
nimtest_no_color_env_var = "NIMTEST_NO_COLOR"
type
FnMatchers* = seq[string]
Conf* = object
basedir, conf_fname: string
fn_matchers: seq[string]
monitor_fn_matchers: seq[string]
debug_mode, nocolor, norun, notify, skipfirst: bool
compiler_args: string
junit_output_filename: string
Summary = ref object of RootObj
success_cnt, fail_cnt, fail_to_compile_cnt: int
Notifier = object
enabled: bool
when defined(linux):
client: NotifyClient
proc print_help() =
echo """Welcome to Nim test runner.
This command runs the Nim compiler against a set of test files.
It supports globbing and can run tests automatically when source files are changed.
Syntax:
testrunner ['test filename globbing'...] [-m [:'monitored filename globbing']] [ -- compiler flags]
testrunner 'tests/**/*.nim'
testrunner -m
testrunner mytest.nim 'tests/*.nim' -m:'**/*.nim' -d -- -d:ssl
testrunner (-h | --help)
Options:
-h --help show help
-d --debug debug mode
-m --monitor without any argument, monitor **/*.nim
-m:'<glob>' monitor globbing. Remember the colon ":"
--basedir <dir> basedir
-c --conf config file path
-o --norun do not run test files, run "nim c" only
-s --skipfirst skip running tests when testrunner is started. Useful with -m
-q --nonotify do not send desktop notifications (enabled only with -m on Linux)
--junit:<fname> write out JUnit summary (default: junit.xml)
Protect globbings with single quotes.
Double asterisk "**" matches nested subdirectories.
Anything after "--" will be used as a compiler option for Nim.
If no test globs are passed, the following will be used:
test_*.nim test/*.nim tests/*.nim
Testrunner will also parse .testrunner.conf if available, unless
a different config file is specified.
"""
quit()
proc parse_cli_options(): Conf =
result = Conf(
basedir: get_current_dir(),
fn_matchers: @[],
monitor_fn_matchers: @[],
compiler_args: "",
nocolor: false,
norun: false,
notify: true,
skipfirst: false,
junit_output_filename: "",
)
var p = initOptParser()
while true:
p.next()
case p.kind
of cmdEnd:
break
of cmdArgument:
# Argument: test filename matcher
result.fn_matchers.add p.key
of cmdLongOption, cmdShortOption:
if p.key == "":
# "--" is handled as empty key and empty var
# anything after this is to be passed to the compiler
# as it is
result.compiler_args = p.cmdLineRest()
break
case p.key
of "help", "h":
print_help()
of "debug", "d":
result.debug_mode = true
of "nocolor", "b":
result.nocolor = true
of "norun", "o":
result.norun = true
of "nonotify", "q":
result.notify = false
of "basedir":
result.basedir = p.val.expand_tilde()
of "monitor", "m":
if p.val == "":
# no pattern is passed, add default value
result.monitor_fn_matchers.add default_monitor_fn_matcher
else:
result.monitor_fn_matchers.add p.val
of "skipfirst", "s":
result.skipfirst = true
of "conf", "c":
if p.val.len == 0:
echo "empty conf file"
quit()
result.conf_fname = p.val
of "junit":
result.junit_output_filename =
if p.val == "": "junit.xml"
else: p.val
proc load_config_file(conf: var Conf) =
## Load config file
if conf.conf_fname.len == 0:
if existsFile(default_conf_fname):
conf.conf_fname = default_conf_fname
else:
return
var f = newFileStream(conf.conf_fname, fmRead)
if f == nil:
echo("cannot open conf file '$#'" % conf.conf_fname)
quit()
var p: CfgParser
p.open(f, conf.conf_fname)
while true:
var e = next(p)
case e.kind
of cfgKeyValuePair:
case e.key:
of "basedir":
conf.basedir = e.value.expand_tilde()
of "glob_include":
conf.fn_matchers.add e.value
of cfgOption:
discard
of cfgEof:
break
of cfgSectionStart:
discard
of cfgError:
echo(e.msg)
close p
proc parse_options_and_config_file(): Conf =
var conf = parse_cli_options()
conf.load_config_file()
if conf.fn_matchers.len == 0:
conf.fn_matchers = default_test_fn_matchers
set_current_dir(conf.basedir)
return conf
proc scan_test_files(conf: Conf): seq[string] =
## Scan for test files
for full_fname in conf.basedir.walkDirRec():
let fname = full_fname[conf.basedir.len+1..^1]
if match_filename(fname, conf.fn_matchers):
result.add fname
proc newNotifier(enabled: bool): Notifier =
## Init desktop notifier
when defined(linux):
result.enabled = enabled
result.client = newNotifyClient("testrunner")
result.client.set_app_name("testrunner")
proc send(notifier: Notifier, msg: string) =
## Send notification
when defined(linux):
notifier.client.send_new_notification("normal", msg, "STOCK_YES",
urgency=NotificationUrgency.Normal, timeout=2)
proc safe_write(path, contents: string) =
## Create dirs, write file atomically
path.splitPath.head.createDir
let tmp_fname = path & ".tmp"
try:
tmp_fname.writeFile(contents)
moveFile(tmp_fname, path)
except Exception:
raise newException(Exception,
"Unable to save file $#: $#" % [path, getCurrentExceptionMsg()])
proc generate_junit_summary(fname: string, testsuites: JUnitTestSuites) =
## Generate and write out a test summary in JUnit format
## One Nim unittest file maps into one JUnitTestSuite
include "junitxml.tmpl"
let contents = testsuites.generate_junit()
safe_write(fname, contents)
proc run_tests(conf: Conf, old_summary: var Summary, notifier: Notifier) =
## Run tests
let t0 = epochTime()
var summary = Summary()
var testsuites = JUnitTestSuites()
testsuites.suites = @[]
let test_fnames = conf.scan_test_files()
if test_fnames.len == 1:
echo "Compiling test file..."
else:
echo "Compiling $# test files..." % $test_fnames.len
var compiled_test_fnames: seq[string] = @[]
for test_fn in test_fnames:
let cmd = "nim c $# $#" % [conf.compiler_args, test_fn]
if conf.debug_mode:
echo "Running: $#" % cmd
let exit_code = execCmd(cmd)
if exit_code == 0:
assert test_fn.endswith(".nim")
compiled_test_fnames.add test_fn[0..^5]
else:
summary.fail_to_compile_cnt.inc
testsuites.errors.inc
echo "Failed to compile $#" % test_fn
if conf.norun == false:
echo "Running $# test files..." % $compiled_test_fnames.len
for fn in compiled_test_fnames:
let cmd = "./" & fn
echo "Running: $#" % cmd
if conf.nocolor:
putEnv(nimtest_no_color_env_var, "1")
let t1 = epochTime()
let exit_code = execCmd(cmd)
let elapsed = epochTime() - t1
var ts = JUnitTestSuite(name:fn)
if exit_code == 0:
summary.success_cnt.inc
testsuites.tests.inc
ts.tests.inc
let tc = JUnitTestCase(name:fn, status:"PASSED",
assertions:1, time:elapsed)
ts.time = elapsed
ts.testcases = @[tc]
else:
summary.fail_cnt.inc exit_code
testsuites.failures.inc exit_code
ts.failures.inc exit_code
testsuites.tests.inc # TODO: correct?
ts.tests.inc # TODO: correct?
let tc = JUnitTestCase(name:fn, status:"FAILED",
assertions:1, time:elapsed)
ts.time = elapsed
ts.testcases = @[tc]
# TODO: more detailed report
testsuites.suites.add ts
let elapsed = formatFloat(epochTime() - t0, precision=2)
testsuites.time = epochTime() - t0
let col = not conf.nocolor
if col:
let symbol_color =
if summary.fail_cnt == 0: success_color
else: fail_color
styledWriteLine(stdout,
symbol_color, symbol & " ", resetStyle,
" Successful: ",
success_color, $summary.success_cnt, resetStyle,
" Failed: ",
fail_color, $summary.fail_cnt, resetStyle,
" Failed to compile: ",
fail_color, $summary.fail_to_compile_cnt, resetStyle,
" Elapsed time: $#s" % elapsed,
)
else:
echo "Successful: $# Failed: $# Elapsed time: $#s" % [
$summary.success_cnt, $summary.fail_cnt, elapsed]
if notifier.enabled:
let fixed = old_summary.fail_cnt - summary.fail_cnt
if fixed > 0:
notifier.send("$# test fixed" % $fixed)
elif fixed < 0:
notifier.send("$# test broken" % $(fixed * -1))
old_summary = summary
if conf.junit_output_filename != "":
generate_junit_summary(conf.junit_output_filename, testsuites)
# globals used for the fswatch callback
var summary = Summary()
let conf = parse_options_and_config_file()
var notifier = newNotifier(conf.notify)
var last_completion_time = getTime()
proc run_tests() =
run_tests(conf, summary, notifier)
proc main() =
if conf.skipfirst == false:
run_tests()
if conf.monitor_fn_matchers.len == 0:
quit()
# File change monitoring
when not defined(nofswatch):
var data = ""
proc callback(eg: EventGroup) =
for e in eg:
if match_filename(e.path, conf.monitor_fn_matchers):
# Often the callback is called more than once for a change.
# e.time has milliseconds set to 0 - callbacks for different events can carry the same e.time
# Unfortunately this creates a "quiet" period up to a second after a run.
if last_completion_time < e.time:
if conf.debug_mode:
echo "Change detected in ", e.path
run_tests()
last_completion_time = getTime()
return
var monitor = newMonitor(latency=0.01)
monitor.add_event_type_filter({Created})
monitor.add_event_type_filter({Updated})
monitor.add_event_type_filter({AttributeModified})
monitor.add(conf.basedir)
monitor.set_recursive(true)
monitor.setCallback(callback)
if conf.debug_mode:
echo "Monitoring dir: ", conf.basedir
monitor.start()
when isMainModule:
main()