nim-testutils/ntu.nim

475 lines
14 KiB
Nim

import
std/[hashes, random, tables, sequtils, strtabs, strutils,
os, osproc, terminal, times, pegs, algorithm],
testutils/[spec, config, helpers, fuzzing_engines]
#[
The runner will look recursively for all *.test files at given path. A
test file should have at minimum a program name. This is the name of the
nim source minus the .nim extension)
]#
# Code is here and there influenced by nim testament tester and unittest
# module.
const
defaultOptions = "--verbosity:1 --warnings:on --skipUserCfg:on "
backendOrder = @["c", "cpp", "js"]
type
TestStatus* = enum
OK
FAILED
SKIPPED
INVALID
#[
If needed, pass more info to the logresult via a TestResult object
TestResult = object
status: TestStatus
compileTime: float
fileSize: uint
]#
ThreadPayload = object
core: int
spec: TestSpec
TestThread = Thread[ThreadPayload]
TestError* = enum
SourceFileNotFound
ExeFileNotFound
OutputFileNotFound
CompileError
RuntimeError
OutputsDiffer
FileSizeTooLarge
CompileErrorDiffers
BackendTests = TableRef[string, seq[TestSpec]]
proc logFailure(test: TestSpec; error: TestError;
data: varargs[string] = [""]) =
case error
of SourceFileNotFound:
styledEcho(fgYellow, styleBright, "source file not found: ",
resetStyle, test.source)
of ExeFileNotFound:
styledEcho(fgYellow, styleBright, "executable file not found: ",
resetStyle, test.binary)
of OutputFileNotFound:
styledEcho(fgYellow, styleBright, "file not found: ",
resetStyle, data[0])
of CompileError:
styledEcho(fgYellow, styleBright, "compile error:\p",
resetStyle, data[0])
of RuntimeError:
styledEcho(fgYellow, styleBright, "runtime error:\p",
resetStyle, data[0])
of OutputsDiffer:
styledEcho(fgYellow, styleBright, "outputs are different:\p",
resetStyle,"Expected output to $#:\p$#" % [data[0], data[1]],
"Resulted output to $#:\p$#" % [data[0], data[2]])
of FileSizeTooLarge:
styledEcho(fgYellow, styleBright, "file size is too large: ",
resetStyle, data[0] & " > " & $test.maxSize)
of CompileErrorDiffers:
styledEcho(fgYellow, styleBright, "compile error is different:\p",
resetStyle, data[0])
styledEcho(fgCyan, styleBright, "compiler: ", resetStyle,
"$# $# $# $#" % [defaultOptions,
test.flags,
test.config.compilationFlags,
test.source])
template withinDir(dir: string; body: untyped): untyped =
## run the body with a specified directory, returning to current dir
let
cwd = getCurrentDir()
setCurrentDir(dir)
try:
body
finally:
setCurrentDir(cwd)
proc logResult(testName: string, status: TestStatus, time: float) =
var color = block:
case status
of OK: fgGreen
of FAILED: fgRed
of SKIPPED: fgYellow
of INVALID: fgRed
styledEcho(styleBright, color, "[", $status, "] ",
resetStyle, testName,
fgYellow, " ", time.formatFloat(ffDecimal, 3), " s")
proc logResult(testName: string, status: TestStatus) =
var color = block:
case status
of OK: fgGreen
of FAILED: fgRed
of SKIPPED: fgYellow
of INVALID: fgRed
styledEcho(styleBright, color, "[", $status, "] ",
resetStyle, testName)
template time(duration, body): untyped =
let t0 = epochTime()
block:
body
duration = epochTime() - t0
proc composeOutputs(test: TestSpec, stdout: string): TestOutputs =
## collect the outputs for the given test
result = newTestOutputs()
for name, expected in test.outputs.pairs:
if name == "stdout":
result[name] = stdout
else:
if not fileExists(name):
continue
result[name] = readFile(name)
removeFile(name)
proc cmpOutputs(test: TestSpec, outputs: TestOutputs): TestStatus =
## compare the test program's outputs to those expected by the test
result = OK
for name, expected in test.outputs.pairs:
if name notin outputs:
logFailure(test, OutputFileNotFound, name)
result = FAILED
continue
let
testOutput = outputs[name]
# Would be nice to do a real diff here instead of simple compare
if test.timestampPeg.len > 0:
if not cmpIgnorePegs(testOutput, expected,
peg(test.timestampPeg), pegXid):
logFailure(test, OutputsDiffer, name, expected, testOutput)
result = FAILED
else:
if not cmpIgnoreDefaultTimestamps(testOutput, expected):
logFailure(test, OutputsDiffer, name, expected, testOutput)
result = FAILED
proc compile(test: TestSpec; backend: string): TestStatus =
## compile the test program for the requested backends
block escapeBlock:
if not fileExists(test.source):
logFailure(test, SourceFileNotFound)
result = FAILED
break escapeBlock
let
binary = test.binary(backend)
var
cmd = findExe("nim")
cmd &= " " & backend
cmd &= " --nimcache:" & test.config.cache(backend)
cmd &= " --out:" & binary
cmd &= " " & defaultOptions
cmd &= " " & test.flags
cmd &= " " & test.config.compilationFlags
cmd &= " " & test.source.quoteShell
var
c = parseCmdLine(cmd)
p = startProcess(command=c[0], args=c[1.. ^1],
options={poStdErrToStdOut, poUsePath})
try:
let
compileInfo = parseCompileStream(p, p.outputStream)
if compileInfo.exitCode != 0:
if test.compileError.len == 0:
logFailure(test, CompileError, compileInfo.fullMsg)
result = FAILED
break escapeBlock
else:
if test.compileError == compileInfo.msg and
(test.errorFile.len == 0 or test.errorFile == compileInfo.errorFile) and
(test.errorLine == 0 or test.errorLine == compileInfo.errorLine) and
(test.errorColumn == 0 or test.errorColumn == compileInfo.errorColumn):
result = OK
else:
logFailure(test, CompileErrorDiffers, compileInfo.fullMsg)
result = FAILED
break escapeBlock
# Lets also check file size here as it kinda belongs to the
# compilation result
if test.maxSize != 0:
var size = getFileSize(binary)
if size > test.maxSize:
logFailure(test, FileSizeTooLarge, $size)
result = FAILED
break escapeBlock
result = OK
finally:
close(p)
proc threadedExecute(payload: ThreadPayload) {.thread.}
proc spawnTest(child: var Thread[ThreadPayload]; test: TestSpec;
core: int): bool =
## invoke a single test on the given thread/core; true if we
## pinned the test to the given core
assert core >= 0
child.createThread(threadedExecute,
ThreadPayload(core: core, spec: test))
# set cpu affinity if requested (and cores remain)
if CpuAffinity in test.config.flags:
if core < countProcessors():
child.pinToCpu core
result = true
proc execute(test: TestSpec): TestStatus =
## invoke a single test and return a status
var
# FIXME: pass a backend
cmd = test.binary
# output the test stage if necessary
if test.stage.len > 0:
echo 20.spaces & test.stage
if not fileExists(cmd):
result = FAILED
logFailure(test, ExeFileNotFound)
else:
withinDir parentDir(test.path):
cmd = cmd.quoteShell & " " & test.args
let
(output, exitCode) = execCmdEx(cmd)
if exitCode != 0:
# parseExecuteOutput() # Need to parse the run time failures?
logFailure(test, RuntimeError, output)
result = FAILED
else:
let
outputs = test.composeOutputs(output)
result = test.cmpOutputs(outputs)
# perform an update of the testfile if requested and required
if UpdateOutputs in test.config.flags and result == FAILED:
test.rewriteTestFile(outputs)
# we'll call this a `skip` because it's not strictly a failure
# and we want any dependent testing to proceed as usual.
result = SKIPPED
proc executeAll(test: TestSpec): TestStatus =
## run a test and any dependent children, yielding a single status
when compileOption("threads"):
try:
var
thread: TestThread
# we spawn and join the test here so that it can receive
# cpu affinity via the standard thread.pinToCpu method
discard thread.spawnTest(test, 0)
thread.joinThreads
except:
# any thread(?) exception is a failure
result = FAILED
else:
# unthreaded serial test execution
result = SKIPPED
while test != nil and result in {OK, SKIPPED}:
result = test.execute
test = test.child
proc threadedExecute(payload: ThreadPayload) {.thread.} =
## a thread in which we'll perform a test execution given the payload
var
result = FAILED
if payload.spec.child == nil:
{.gcsafe.}:
result = payload.spec.execute
else:
try:
var
child: TestThread
discard child.spawnTest(payload.spec.child, payload.core + 1)
{.gcsafe.}:
result = payload.spec.execute
child.joinThreads
except:
result = FAILED
if result == FAILED:
raise newException(CatchableError, payload.spec.stage & " failed")
proc optimizeOrder(tests: seq[TestSpec];
order: set[SortBy]): seq[TestSpec] =
## order the tests by how recently each was modified
template whenWritten(path: string): Time =
path.getFileInfo(followSymlink = true).lastWriteTime
result = tests
for s in SortBy.low .. SortBy.high:
if s in order:
case s
of Test:
result = result.sortedByIt it.path.whenWritten
of Source:
result = result.sortedByIt it.source.whenWritten
of Reverse:
result.reverse
of Random:
result.shuffle
proc scanTestPath(path: string): seq[string] =
## add any tests found at the given path
if fileExists(path):
result.add path
else:
for file in walkDirRec path:
if file.endsWith ".test":
result.add file
proc test(test: TestSpec; backend: string): TestStatus =
let
config = test.config
var
duration: float
try:
time duration:
# perform all tests in the test file
result = test.executeAll
finally:
logResult(test.name, result, duration)
proc buildBackendTests(config: TestConfig;
tests: seq[TestSpec]): BackendTests =
## build the table mapping backend to test inputs
result = newTable[string, seq[TestSpec]](4)
for spec in tests.items:
for backend in config.backends.items:
assert backend != ""
if backend in result:
if spec notin result[backend]:
result[backend].add spec
else:
result[backend] = @[spec]
proc removeCaches(config: TestConfig; backend: string) =
## cleanup nimcache directories between backend runs
removeDir config.cache(backend)
# we want to run tests on "native", first.
proc performTesting(config: TestConfig;
backend: string; tests: seq[TestSpec]): TestStatus =
var
successful, skipped, invalid, failed = 0
dedupe: CountTable[Hash]
assert backend != ""
# perform each test in an optimized order
for spec in tests.optimizeOrder(config.orderBy).items:
block escapeBlock:
if spec.program.len == 0:
# a program name is bare minimum of a test file
result = INVALID
invalid.inc
logResult(spec.program & " for " & spec.name, result)
break escapeBlock
if spec.skip or hostOS notin spec.os or config.shouldSkip(spec.name):
result = SKIPPED
skipped.inc
logResult(spec.program & " for " & spec.name, result)
break escapeBlock
let
build = spec.binaryHash(backend)
if build notin dedupe:
dedupe.inc build
# compile the test program for all backends
var
duration: float
try:
time duration:
result = compile(spec, backend)
if result != OK:
failed.inc
break escapeBlock
finally:
logResult("compiled " & spec.program & " for " & spec.name,
result, duration)
if result == OK:
successful.inc
let nonSuccesful = skipped + invalid + failed
styledEcho(styleBright, "Finished run for $#: $#/$# OK, $# SKIPPED, $# FAILED, $# INVALID" %
[backend, $successful, $(tests.len),
$skipped, $failed, $invalid])
for spec in tests.items:
try:
# this may fail in 64-bit AppVeyor images with "The process cannot
# access the file because it is being used by another process.
# [OSError]"
let
fn = spec.binary(backend)
if fileExists(fn):
removeFile(fn)
except CatchableError as e:
echo e.msg
if 0 == tests.len - successful - nonSuccesful:
config.removeCaches(backend)
if failed != 0:
result = FAILED
elif invalid != 0:
result = INVALID
else:
result = OK
proc main(): int =
let config = processArguments()
case config.cmd
of Command.test:
let testFiles = scanTestPath(config.path)
if testFiles.len == 0:
styledEcho(styleBright, "No test files found")
result = 1
else:
var
tests = testFiles.mapIt config.parseTestFile(it)
backends = config.buildBackendTests(tests)
# c > cpp > js
for backend in backendOrder:
assert backend != ""
# if we actually need to do anything on the given backend
if backend notin backends:
continue
let
tests = backends[backend]
try:
if OK != config.performTesting(backend, tests):
quit QuitFailure
finally:
backends.del(backend)
for backend, tests in backends.pairs:
assert backend != ""
if OK != config.performTesting(backend, tests):
quit QuitFailure
of Command.fuzz:
runFuzzer(config.target, config.fuzzer, config.corpusDir)
of noCommand:
discard
when isMainModule:
quit main()