dmd/compiler/test/tools/d_do_test.d
Dennis 3daeb3a33d
Improve diff based AUTO_UPDATE (#20703)
Co-authored-by: Dennis Korpel <dennis@sarc.nl>
2025-01-15 23:25:32 +01:00

2382 lines
81 KiB
D
Executable file

#!/usr/bin/env rdmd
/**
* D testing tool.
*
* This module implements the test runner for all tests except `unit`.
*
* The general procedure is:
*
* 1. Parse the environment variables (`processEnvironment`)
* 2. Extract test parameters from the source file (`gatherTestParameters`)
* [3. Compile non-D sources (` collectExtraSources`)]
* 4. Compile the test file (`tryMain`)
* 5. Verify the compiler output (`compareOutput`)
* [6. Run the generated executable (`tryMain`) ]
* [5. Verify the executable's output (`compareOutput`) ]
* [7. Run post-test steps (`tryMain`) ]
* 8. Remove intermediate files (`tryMain`)
*
* Optional steps are marked with [...]
*/
module d_do_test;
import std.algorithm;
import std.array;
import std.conv;
import std.datetime.stopwatch;
import std.datetime.systime;
import std.exception;
import std.file;
import std.format;
import std.meta : AliasSeq;
import std.process;
import std.random;
import std.range : chain, choose, roundRobin, takeOne;
import std.regex;
import std.path;
import std.stdio;
import std.string;
import core.sys.posix.sys.wait;
/// Absolute path to the test directory
const dmdTestDir = __FILE_FULL_PATH__.dirName.dirName;
version(Win32)
{
extern(C) int putenv(const char*);
}
/// Prints the `--help` information
void usage()
{
write("d_do_test <test_file>\n"
~ "\n"
~ " Note: this program is normally called through the Makefile, it"
~ " is not meant to be called directly by the user.\n"
~ "\n"
~ " example: d_do_test runnable/pi.d\n"
~ "\n"
~ " relevant environment variables:\n"
~ " ARGS: set to execute all combinations of\n"
~ " REQUIRED_ARGS: arguments always passed to the compiler\n"
~ " DMD: compiler to use, ex: ../src/dmd (required)\n"
~ " CC: C compiler to use, ex: cl, cc\n"
~ " CXX: C++ compiler to use, ex: cl, g++\n"
~ " OS: windows, linux, freebsd, osx, openbsd, netbsd, dragonflybsd\n"
~ " RESULTS_DIR: base directory for test results\n"
~ " MODEL: 32 or 64 (required)\n"
~ " AUTO_UPDATE: set to 1 to auto-update mismatching test output\n"
~ " PRINT_RUNTIME: set to 1 to print test runtime\n"
~ "\n"
~ " windows vs non-windows portability env vars:\n"
~ " DSEP: \\\\ or /\n"
~ " SEP: \\ or / (required)\n"
~ " OBJ: .obj or .o (required)\n"
~ " EXE: .exe or <null> (required)\n");
}
/// Type of test to execute (mapped to the subdirectories)
enum TestMode
{
COMPILE, /// compilable
FAIL_COMPILE, /// fail_compilation
RUN, /// runnable, runnable_cxx
DSHELL, /// dshell
}
/// Test parameters specified in the source file
/// (conditionally expanded depending on the environment)
struct TestArgs
{
TestMode mode; /// Test type based on the directory
bool compileSeparately; /// `COMPILE_SEPARATELY`: compile each source file separately
bool link; /// `LINK`: force linking for `fail_compilation` & `compilable` tests
bool clearDflags; /// `DFLAGS`: whether DFLAGS should be cleared before invoking dmd
string executeArgs; /// `EXECUTE_ARGS`: arguments passed to the compiled executable (for `runnable[_cxx]`)
string cxxflags; /// `CXXFLAGS`: arguments passed to $CXX when compiling `EXTRA_CPP_SOURCES`
string[] sources; /// `EXTRA_SOURCES`: additional D sources (+ main source file)
string[] compiledImports; /// `COMPILED_IMPORTS`: files compiled alongside the main source
string[] cppSources; /// `EXTRA_CPP_SOURCES`: additional C++ sources
string[] objcSources; /// `EXTRA_OBJC_SOURCES`: additional Objective-C sources
string permuteArgs; /// `PERMUTE_ARGS`: set of dmd arguments to permute for multiple test runs
string[] argSets; /// `ARG_SETS`: selection of dmd arguments to use in different test runs
string compileOutput; /// `TEST_OUTPUT`: expected output of dmd
string compileOutputFile; /// `TEST_OUTPUT_FILE`: file containing the expected `TEST_OUTPUT`
string runOutput; /// `RUN_OUTPUT`: expected output of the compiled executable
ubyte runReturn; /// `RUN_RETURN` expected exit code of the executable when run
string gdbScript; /// `GDB_SCRIPT`: script executed when running the compiled executable in GDB
string gdbMatch; /// `GDB_MATCH`: regex describing the expected output from executing `GDB_SSCRIPT`
string postScript; /// `POSTSCRIPT`: bash script executed after a successful test
string[] outputFiles; /// generated files appended to the compilation output
string transformOutput; /// Transformations for the compiler output
string requiredArgs; /// `REQUIRED_ARGS`: dmd arguments passed when compiling D sources
string requiredArgsForLink; /// `COMPILE_SEPARATELY`: dmd arguments passed when linking separately compiled objects
string disabledReason; /// `DISABLED`: reason to skip this test or empty, if the test is not disabled
/// Returns: whether this disabled due to some reason
bool isDisabled() const { return disabledReason.length != 0; }
}
/// Test parameters specified in the environment (e.g. target model)
/// which are shared between all tests
struct EnvData
{
string all_args; /// `ARGS`: arguments to test in permutations
string dmd; /// `DMD`: compiler under test
string results_dir; /// `RESULTS_DIR`: directory for temporary files
string sep; /// `SEP`: directory separator (`/` or `\`)
string dsep; /// `DSEP`: double directory separator ( `/` or `\\`)
string obj; /// `OBJ`: object file extension (`.o` or `.obj`)
string exe; /// `EXE`: executable file extension (none or `.exe`)
string os; /// `OS`: host operating system (`linux`, `windows`, ...)
string compiler; /// `HOST_DMD`: host D compiler
string ccompiler; /// `CC`: host C compiler
string cxxcompiler; /// `CXX`: host C++ compiler
string model; /// `MODEL`: target model (`32` or `64`)
string required_args; /// `REQUIRED_ARGS`: flags added to the tests `REQUIRED_ARGS` parameter
string cxxCompatFlags; /// Additional flags passed to $(compiler) when `EXTRA_CPP_SOURCES` is present
string[] picFlag; /// Compiler flag for PIC (if requested from environment)
bool dobjc; /// `D_OBJC`: run Objective-C tests
bool coverage_build; /// `COVERAGE`: coverage build, skip linking & executing to save time
bool autoUpdate; /// `AUTO_UPDATE`: update `(TEST|RUN)_OUTPUT` on missmatch
bool printRuntime; /// `PRINT_RUNTIME`: Print time spent on a single test
bool tryDisabled; /// `TRY_DISABLED`:Silently try disabled tests (ignore failure and report success)
}
/++
Creates a new EnvData instance based on the current environment.
Other code should not read from the environment.
Returns: an initialized EnvData instance
++/
immutable(EnvData) processEnvironment()
{
static string envGetRequired(in char[] name)
{
if (auto value = environment.get(name))
return value;
writefln("Error: Missing environment variable '%s', was this called through the Makefile?",
name);
throw new SilentQuit();
}
EnvData envData;
envData.all_args = environment.get("ARGS");
envData.results_dir = envGetRequired("RESULTS_DIR");
envData.sep = envGetRequired ("SEP");
envData.dsep = environment.get("DSEP");
envData.obj = envGetRequired ("OBJ");
envData.exe = envGetRequired ("EXE");
envData.os = environment.get("OS");
envData.dmd = replace(envGetRequired("DMD"), "/", envData.sep);
envData.compiler = "dmd"; //should be replaced for other compilers
envData.ccompiler = environment.get("CC");
envData.cxxcompiler = environment.get("CXX");
envData.model = envGetRequired("MODEL");
envData.required_args = environment.get("REQUIRED_ARGS");
envData.dobjc = environment.get("D_OBJC") == "1";
envData.coverage_build = environment.get("DMD_TEST_COVERAGE") == "1";
envData.autoUpdate = environment.get("AUTO_UPDATE", "") == "1";
envData.printRuntime = environment.get("PRINT_RUNTIME", "") == "1";
envData.tryDisabled = environment.get("TRY_DISABLED") == "1";
enforce(envData.sep.length == 1,
"Path separator must be a single character, not: `"~envData.sep~"`");
if (envData.ccompiler.empty)
{
if (envData.os == "windows")
envData.ccompiler = "cl";
else
envData.ccompiler = "cc";
}
if (envData.cxxcompiler.empty)
{
if (envData.os == "windows")
envData.cxxcompiler = "cl";
else
envData.cxxcompiler = "c++";
}
version (Windows) {} else
{
version(X86_64)
envData.picFlag = ["-fPIC"];
if (environment.get("PIC", null) == "1")
envData.picFlag = ["-fPIC"];
}
switch (envData.compiler)
{
case "dmd":
case "ldc":
version (CppRuntime_GNU)
envData.cxxCompatFlags = " -L-lstdc++ -L--no-demangle";
else version (CppRuntime_LLVM)
envData.cxxCompatFlags = " -L-lc++ -L--no-demangle";
else version (CppRuntime_Gcc)
envData.cxxCompatFlags = " -L-lstdc++ -L--no-demangle";
else version (CppRuntime_Clang)
envData.cxxCompatFlags = " -L-lc++ -L--no-demangle";
break;
case "gdc":
envData.cxxCompatFlags = "-Xlinker -lstdc++ -Xlinker --no-demangle";
break;
default:
writeln("Unknown compiler: ", envData.compiler);
throw new SilentQuit();
}
return cast(immutable) envData;
}
/**
* Read the single-line test parameter `token` from the source code which
* might be defined multiple times. All definitions found will be joined
* into a single string using `multilineDelimiter` as a separator.
*
* This will skip conditional parameters declared as `<token>(<environment>)`
* if the specified environment doesn't match the passed `envData`, e.g.
*
* ---
* REQURIRED_ARGS(linux): -ignore
* PERMUTE_ARGS(windows64): -ignore
* ---
*
* Params:
* envData = environment data
* file = source code
* token = test parameter
* result = variable to store the parameter
* multilineDelimiter = separator for multiple declarations
*
* Returns: whether the parameter was found in the source code
*/
bool findTestParameter(const ref EnvData envData, string file, string token, ref string result, string multiLineDelimiter = " ")
{
if (!consumeNextToken(file, token, envData))
return false;
auto lineEndR = std.string.indexOf(file, "\r");
auto lineEndN = std.string.indexOf(file, "\n");
auto lineEnd = lineEndR == -1 ?
(lineEndN == -1 ? file.length : lineEndN) :
(lineEndN == -1 ? lineEndR : min(lineEndR, lineEndN));
result = file[0 .. lineEnd];
const commentStart = std.string.indexOf(result, "//");
if (commentStart != -1)
result = result[0 .. commentStart];
result = strip(result);
string result2;
if (findTestParameter(envData, file[lineEnd .. $], token, result2, multiLineDelimiter))
{
if (result2.length > 0)
{
if (result.length == 0)
result = result2;
else
result ~= multiLineDelimiter ~ result2;
}
}
// fix-up separators
result = result.unifyDirSep(envData.sep);
return true;
}
unittest
{
immutable file = `
/*
Here's a link
FOO: a b
FOO: c
*/
void main() {}
// BAR(windows): all_win
// BAR(windows64): 64_win
int i;
/* REQUIRED_ARGS: -O
* PERMUTE_ARGS:
*/
// COMPILE_SEPARATELY:
import foo.bar;
`;
immutable EnvData win32 = {
os: "windows",
model: "32",
};
immutable EnvData win64 = {
os: "windows",
model: "64",
};
immutable EnvData linux = {
os: "linux",
model: "64",
};
string found;
assert(!findTestParameter(linux, file, "OTHER", found));
assert(found is null);
assert(findTestParameter(win64, file, "FOO", found));
assert(found == "a b c");
found = null;
assert(findTestParameter(win64, file, "FOO", found, ";"));
assert(found == "a b;c");
found = null;
assert(findTestParameter(win64, file, "BAR", found));
assert(found == "all_win 64_win");
found = null;
assert(findTestParameter(win32, file, "BAR", found));
assert(found == "all_win");
found = null;
assert(!findTestParameter(linux, file, "BAR", found));
assert(found is null);
assert(findTestParameter(linux, file, "PERMUTE_ARGS", found));
assert(found == "");
found = null;
assert(findTestParameter(linux, file, "REQUIRED_ARGS", found));
assert(found == "-O");
found = null;
assert(findTestParameter(linux, file, "COMPILE_SEPARATELY", found));
assert(found == "");
}
/**
* Read the multi-line test parameter `token` from the source code and joins
* multiple definitions into a single string.
*
* ```
* TEST_OUTPUT:
* ---
* Hello, World!
* ---
* ```
*
* Params:
* file = source code
* token = test parameter
* result = variable to store the parameter
* envData = environment data
*
* Returns: whether the parameter was found in the source code
*/
bool findOutputParameter(string file, string token, out string result, ref const EnvData envData)
{
bool found = false;
while (consumeNextToken(file, token, envData))
{
found = true;
enum embed_sep = "---";
auto n = std.string.indexOf(file, embed_sep);
enforce(n != -1, "invalid "~token~" format");
n += embed_sep.length;
while (file[n] == '-') ++n;
if (file[n] == '\r') ++n;
if (file[n] == '\n') ++n;
file = file[n .. $];
auto iend = std.string.indexOf(file, embed_sep);
enforce(iend != -1, "invalid TEST_OUTPUT format");
result ~= file[0 .. iend];
while (file[iend] == '-') ++iend;
file = file[iend .. $];
}
if (found)
{
result = std.string.strip(result);
result = result.unifyNewLine().unifyDirSep(envData.sep);
result = result ? result : ""; // keep non-null
}
return found;
}
unittest
{
immutable EnvData linux = { os: "linux", model: "64", sep: "/ " };
immutable file = `
/*
Here's a link
TEST_OUTPUT:
---
Hello, World
---
*/
void main() {}
/*
TEST_OUTPUT(linux):
---
Have a nice day
---
*/
/*
TEST_OUTPUT(linux32):
---
Ignored
---
*/
void foo() {}
/*
MISSING:
*/
/*
INCOMPLETE:
---
*/
`;
string found;
assert(!findOutputParameter(file, "UNKNOWN", found, linux));
assert(found is null);
assert(findOutputParameter(file, "TEST_OUTPUT", found, linux));
assert(found == "Hello, World\nHave a nice day");
found = null;
auto ex = collectException(findOutputParameter(file, "MISSING", found, linux));
assert(ex);
assert(ex.msg == "invalid TEST_OUTPUT format");
assert(found is null);
ex = collectException(findOutputParameter(file, "INCOMPLETE", found, linux));
assert(ex);
assert(ex.msg == "invalid TEST_OUTPUT format");
}
/++
+ Reads the file content to find the next parameter specified by `token`.
+ Ignores conditional parameters that don't apply to the current environment.
+
+ Params:
+ file = file content, will be advanced to the first non-whitespace character
+ of the parameter value - if `token` was found
+ token = requested parameter
+ envData = environment data
+
+ Returns: true if `token` was found
+/
private bool consumeNextToken(ref string file, const string token, ref const EnvData envData)
{
import std.ascii : isWhite;
while (true)
{
const istart = std.string.indexOf(file, token);
if (istart == -1)
return false;
// Examine the preceeding character to avoid false matches
const ch = istart ? file[istart - 1] : ' ';
file = file[istart + token.length .. $];
file = file.stripLeft!(ch => ch == ' '); // Don't read line breaks
// Assume that real test token are preceeded by spaces or comment-related tokens
if (!isWhite(ch) && !among(ch, '/', '*', '+'))
{
debug writeln("Ignoring partial match for token: ", token);
continue;
}
// filter by OS specific setting (os1 os2 ...)
if (file.startsWith("("))
{
auto close = std.string.indexOf(file, ")");
if (close >= 0)
{
// Remove the (<oss>) list from the front of `file``
const oss = split(file[1 .. close], " ");
file = file[close + 1 .. $];
file = file.stripLeft!(ch => ch == ' '); // Don't read line breaks
// Check if the current environment matches an entry in oss, which can either
// be an OS (e.g. "linux") or a combination of OS + MODEL (e.g. "windows32").
if (!oss.canFind!(o => o.skipOver(envData.os) && (o.empty || o == envData.model)))
continue; // Parameter was skipped
}
}
// Skip a trailing colon
if (file.skipOver(":"))
{
file = file.stripLeft!(ch => ch == ' '); // Don't read line breaks
return true;
}
writeln("Test directive `", token,"` must be followed by a `:`");
throw new SilentQuit();
}
}
unittest
{
immutable EnvData linux = { os: "linux", model: "64", sep: "/ " };
immutable file = q{
GOOD: foo
WITH_SPACE : bar
/*
BAD
---
---
*/
//BAD foo
//OPTLINK
// $(TINK foo.bar)
IgnoreSTUFF
IgnoreSTUFF: x
STUFF: someVal
*/
void main() {}
};
string found = file;
assert(!consumeNextToken(found, "UNKNOWN", linux));
assert(found is file);
assert(consumeNextToken(found, "GOOD", linux));
assert(found.startsWith("foo"), found);
found = file;
assert(consumeNextToken(found, "WITH_SPACE", linux));
assert(found.startsWith("bar"), found);
found = file;
assertThrown(consumeNextToken(found, "BAD", linux));
found = file;
assert(!consumeNextToken(found, "LINK", linux));
found = file;
assert(!consumeNextToken(found, "TINK", linux));
found = file;
assert(consumeNextToken(found, "STUFF", linux));
assert(found.startsWith("someVal"), found);
}
/// Replaces the placeholer `${RESULTS_DIR}` with the actual path
/// to `test_results` stored in `envData`.
void replaceResultsDir(ref string arguments, const ref EnvData envData)
{
// Bash would expand this automatically on Posix, but we need to manually
// perform the replacement for Windows compatibility.
arguments = replace(arguments, "${RESULTS_DIR}", envData.results_dir);
}
/// Returns: the reason why this test is disabled or null if it isn't skipped.
string getDisabledReason(string[] disabledPlatforms, const ref EnvData envData)
{
if (disabledPlatforms.length == 0)
return null;
const target = ((envData.os == "windows") ? "win" : envData.os) ~ envData.model;
// allow partial matching, e.g. `win` to disable both win32 and win64
const i = disabledPlatforms.countUntil!(p => target.canFind(p));
if (i != -1)
return "on " ~ disabledPlatforms[i];
return null;
}
unittest
{
immutable EnvData win32 = { os: "windows", model: "32" };
immutable EnvData win64 = { os: "windows", model: "64" };
assert(getDisabledReason(null, win64) is null);
assert(getDisabledReason([ "linux" ], win64) is null);
assert(getDisabledReason([ "linux", "win" ], win64) == "on win");
assert(getDisabledReason([ "linux", "win64" ], win64) == "on win64");
assert(getDisabledReason([ "linux", "win32" ], win64) is null);
assert(getDisabledReason([ "win32" ], win32) == "on win32");
assert(getDisabledReason([ "win32" ], win64) is null);
}
/**
* Reads the test configuration from the source code (using `findTestParameter` and
* `findOutputParameter`) and initializes `testArgs` accordingly. Also merges
* configurations/additional parameters specified in the environment, e.g.
* `REQUIRED_ARGS`.
*
* Params:
* testArgs = test configuration object
* input_dir = test directory (e.g. `runnable`)
* input_file = path to the source file
* envData = environment configurations
*
* Returns: whether this test should be executed (true) or skipped (false)
* Throws: Exception if the test configuration is invalid
*/
bool gatherTestParameters(ref TestArgs testArgs, string input_dir, string input_file, const ref EnvData envData)
{
string file = cast(string)std.file.read(input_file);
string dflagsStr;
testArgs.clearDflags = findTestParameter(envData, file, "DFLAGS", dflagsStr);
enforce(dflagsStr.empty, "The DFLAGS test argument must be empty: It is '" ~ dflagsStr ~ "'");
findTestParameter(envData, file, "REQUIRED_ARGS", testArgs.requiredArgs);
if (envData.required_args.length)
{
if (testArgs.requiredArgs.length)
testArgs.requiredArgs ~= " " ~ envData.required_args;
else
testArgs.requiredArgs = envData.required_args;
}
replaceResultsDir(testArgs.requiredArgs, envData);
if (! findTestParameter(envData, file, "PERMUTE_ARGS", testArgs.permuteArgs))
{
if (testArgs.mode == TestMode.RUN)
testArgs.permuteArgs = envData.all_args;
}
replaceResultsDir(testArgs.permuteArgs, envData);
// remove permute args enforced as required anyway
if (testArgs.requiredArgs.length && testArgs.permuteArgs.length)
{
const required = split(testArgs.requiredArgs);
const newPermuteArgs = split(testArgs.permuteArgs)
.filter!(a => !required.canFind(a))
.join(" ");
testArgs.permuteArgs = newPermuteArgs;
}
// tests can override -verrors by using REQUIRED_ARGS
if (testArgs.mode == TestMode.FAIL_COMPILE)
testArgs.requiredArgs = "-verrors=simple -verrors=0 " ~ testArgs.requiredArgs;
{
string argSetsStr;
findTestParameter(envData, file, "ARG_SETS", argSetsStr, ";");
foreach(s; split(argSetsStr, ";"))
{
replaceResultsDir(s, envData);
testArgs.argSets ~= s;
}
}
// win(32|64) doesn't support pic
// -target/-os may compile for non-PIC targets, let the test take care of -fPIC
if (envData.os == "windows" || testArgs.requiredArgs.canFind("-target", "-os"))
{
auto index = std.string.indexOf(testArgs.permuteArgs, "-fPIC");
if (index != -1)
testArgs.permuteArgs = testArgs.permuteArgs[0 .. index] ~ testArgs.permuteArgs[index+5 .. $];
// Remove the first -fPIC when added via the REQUIRED_ARGS environment variable
// This allows test to explicitly set `-fPIC` if necessary
if (envData.required_args.canFind("-fPIC"))
testArgs.requiredArgs = testArgs.requiredArgs.replaceFirst("-fPIC", "").strip();
}
// clean up extra spaces
testArgs.permuteArgs = strip(replace(testArgs.permuteArgs, " ", " "));
if (findTestParameter(envData, file, "EXECUTE_ARGS", testArgs.executeArgs))
replaceResultsDir(testArgs.executeArgs, envData);
// Always run main even if compiled with '-unittest' but let
// tests switch to another behaviour if necessary
if (!testArgs.executeArgs.canFind("--DRT-testmode"))
testArgs.executeArgs ~= " --DRT-testmode=run-main";
string extraSourcesStr;
findTestParameter(envData, file, "EXTRA_SOURCES", extraSourcesStr);
testArgs.sources = [input_file];
// prepend input_dir to each extra source file
foreach(s; split(extraSourcesStr))
testArgs.sources ~= input_dir ~ "/" ~ s;
{
string compiledImports;
findTestParameter(envData, file, "COMPILED_IMPORTS", compiledImports);
foreach(s; split(compiledImports))
testArgs.compiledImports ~= input_dir ~ "/" ~ s;
}
findTestParameter(envData, file, "CXXFLAGS", testArgs.cxxflags);
string extraCppSourcesStr;
findTestParameter(envData, file, "EXTRA_CPP_SOURCES", extraCppSourcesStr);
testArgs.cppSources = split(extraCppSourcesStr);
if (testArgs.cppSources.length)
testArgs.requiredArgs ~= envData.cxxCompatFlags;
string extraObjcSourcesStr;
auto objc = findTestParameter(envData, file, "EXTRA_OBJC_SOURCES", extraObjcSourcesStr);
if (objc && !envData.dobjc)
return false;
testArgs.objcSources = split(extraObjcSourcesStr);
// swap / with $SEP
if (envData.sep && envData.sep != "/")
foreach (ref s; testArgs.sources)
s = replace(s, "/", to!string(envData.sep));
//writeln ("sources: ", testArgs.sources);
{
string throwAway;
testArgs.link = findTestParameter(envData, file, "LINK", throwAway);
}
// COMPILE_SEPARATELY can take optional compiler switches when link .o files
testArgs.compileSeparately = findTestParameter(envData, file, "COMPILE_SEPARATELY", testArgs.requiredArgsForLink);
string disabledPlatformsStr;
findTestParameter(envData, file, "DISABLED", disabledPlatformsStr);
version (DragonFlyBSD)
{
// DragonFlyBSD is x86_64 only, instead of adding DISABLED to a lot of tests, just exclude them from running
if (testArgs.requiredArgs.canFind("-m32"))
testArgs.disabledReason = "on DragonFlyBSD (no -m32)";
}
version (ARM) enum supportsM64 = false;
else version (MIPS32) enum supportsM64 = false;
else version (PPC) enum supportsM64 = false;
else enum supportsM64 = true;
static if (!supportsM64)
{
if (testArgs.requiredArgs.canFind("-m64"))
testArgs.disabledReason = "because target doesn't support -m64";
}
if (!testArgs.isDisabled)
testArgs.disabledReason = getDisabledReason(split(disabledPlatformsStr), envData);
findTestParameter(envData, file, "TEST_OUTPUT_FILE", testArgs.compileOutputFile);
// Only check for TEST_OUTPUT is no file was given because it would
// partially match TEST_OUTPUT_FILE
if (testArgs.compileOutputFile)
{
// Don't require tests to specify the test directory
testArgs.compileOutputFile = input_dir.buildPath(testArgs.compileOutputFile);
testArgs.compileOutput = readText(testArgs.compileOutputFile)
.unifyNewLine() // Avoid CRLF issues
.strip();
// Only sanitize directory separators from file types that support standalone \
if (!testArgs.compileOutputFile.endsWith(".json"))
testArgs.compileOutput = testArgs.compileOutput.unifyDirSep(envData.sep);
}
else
findOutputParameter(file, "TEST_OUTPUT", testArgs.compileOutput, envData);
string outFilesStr;
findTestParameter(envData, file, "OUTPUT_FILES", outFilesStr);
testArgs.outputFiles = outFilesStr.split(';');
findTestParameter(envData, file, "TRANSFORM_OUTPUT", testArgs.transformOutput);
findOutputParameter(file, "RUN_OUTPUT", testArgs.runOutput, envData);
string dummy;
if (findTestParameter(envData, file, "RUN_RETURN", dummy))
{
import std.conv : to;
scope(failure)
{
writeln("Something went wrong when parsing RUN_RETURN from " ~ dummy);
}
testArgs.runReturn = dummy.to!(typeof(testArgs.runReturn));
}
findOutputParameter(file, "GDB_SCRIPT", testArgs.gdbScript, envData);
findTestParameter(envData, file, "GDB_MATCH", testArgs.gdbMatch);
findTestParameter(envData, file, "POST_SCRIPT", testArgs.postScript);
return true;
}
unittest
{
immutable EnvData linux32 = { os: "linux", model: "32", required_args: "-fPIC" };
immutable EnvData linux64 = { os: "linux", model: "64", required_args: "-fPIC" };
immutable EnvData win64 = { os: "windows", model: "64", };
immutable dir = "runnable";
immutable file = ".d_do_test_unittest_target_example.d";
immutable content = q{
/+
https://foo.bar.
REQUIRED_ARGS: -target=x86-unknown-windows-msvc
REQUIRED_ARGS(linux32): -fPIC
+/
};
std.file.write(file, content);
scope (exit) std.file.remove(file);
TestArgs args;
assert(gatherTestParameters(args, dir, file, win64));
assert(args.requiredArgs == "-target=x86-unknown-windows-msvc", args.requiredArgs);
args = TestArgs.init;
assert(gatherTestParameters(args, dir, file, linux64));
assert(args.requiredArgs == "-target=x86-unknown-windows-msvc", args.requiredArgs);
args = TestArgs.init;
assert(gatherTestParameters(args, dir, file, linux32));
assert(args.requiredArgs == "-target=x86-unknown-windows-msvc -fPIC", args.requiredArgs);
std.file.write(file, "REQUIRED_ARGS: -os=windows");
args = TestArgs.init;
assert(gatherTestParameters(args, dir, file, linux64));
assert(args.requiredArgs == "-os=windows", args.requiredArgs);
}
/// Generates all permutations of the space-separated word contained in `argstr`
string[] combinations(string argstr)
{
string[] results;
string[] args = split(argstr);
long combinations = 1 << args.length;
for (size_t i = 0; i < combinations; i++)
{
string r;
bool printed = false;
for (size_t j = 0; j < args.length; j++)
{
if (i & 1 << j)
{
if (printed)
r ~= " ";
r ~= args[j];
printed = true;
}
}
results ~= r;
}
return results;
}
/// Tries to remove the file identified by `filename` and prints warning on failure
void tryRemove(in char[] filename)
{
if (auto ex = std.file.remove(filename).collectException())
debug writeln("WARNING: Failed to remove ", filename);
}
/**
* Executes `command` while logging the invocation and any output produced into f.
*
* Params:
* f = the logfile
* command = the command to execute
* expectPass = whether the command should succeed
*
* Returns: the output produced by `command`
* Throws:
* Exception if `command` returns another exit code than 0/1 (depending on expectPass)
*/
string execute(ref File f, string command, const ubyte expectedRc)
{
f.writeln(command);
const result = std.process.executeShell(command);
f.write(result.output);
if (result.status < 0)
{
enforce(false, "caught signal: " ~ to!string(result.status));
}
else
{
enforce(result.status == expectedRc, format("Expected rc == %d, but exited with rc == %d", expectedRc, result.status));
}
return result.output;
}
/// add quotes around the whole string if it contains spaces that are not in quotes
string quoteSpaces(string str)
{
if (str.indexOf(' ') < 0)
return str;
bool inquote = false;
foreach(dchar c; str)
if (c == '"')
inquote = !inquote;
else if (c == ' ' && !inquote)
return "\"" ~ str ~ "\"";
return str;
}
/// Replaces non-Unix line endings in `str` with `\n`
string unifyNewLine(string str)
{
// On Windows, Outbuffer.writenl() puts `\r\n` into the buffer,
// then fprintf() adds another `\r` when formatting the message.
// This is why there's a match for `\r\r\n` in this regex.
static re = regex(`\r\r\n|\r\n|\r|\n`, "g");
return std.regex.replace(str, re, "\n");
}
/**
Unifies a text `str` with words that could be DMD path references to a common
separator `sep`. This normalizes the text and allows comparing path output
results between different operating systems.
Params:
str = text to be unified
sep = unification separator to use
Returns: Text with path separator standardized to `sep`.
*/
string unifyDirSep(string str, string sep)
{
static void unifyWordFromBack(char[] r, char sep)
{
foreach_reverse(ref ch; r)
{
// stop at common word boundaries
if (ch == '\n' || ch == '\r' || ch == ' ')
break;
// normalize path characters
if (ch == '\\' || ch == '/')
ch = sep;
}
}
auto mStr = str.dup;
auto remaining = mStr;
alias needles = AliasSeq!(".d", ".di", ".mixin", ".c", ".i", ".h");
enum needlesArray = [needles];
// simple multi-delimiter word identification
while (!remaining.empty)
{
auto res = remaining.find(needles);
if (res[0].empty) break;
auto currentWord = remaining[0 .. res[0].ptr-remaining.ptr];
// skip over current word and matched delimiter
const needleLength = res[1] > 0 ? needlesArray[res[1] - 1].length : 0;
remaining = remaining[currentWord.length + needleLength .. $];
if (remaining.empty ||
remaining.startsWith(" ", "\n", "\r", "-mixin",
"(", ":", "'", "`", "\"", ".", ","))
unifyWordFromBack(currentWord, sep[0]);
}
return mStr.assumeUnique;
}
unittest
{
assert(`fail_compilation/test.d(1) Error: dummy error message for 'test'`.unifyDirSep(`\`)
== `fail_compilation\test.d(1) Error: dummy error message for 'test'`);
assert(`fail_compilation/test.d(1) Error: at fail_compilation/test.d(2)`.unifyDirSep(`\`)
== `fail_compilation\test.d(1) Error: at fail_compilation\test.d(2)`);
assert(`fail_compilation/test.d(1) Error: at fail_compilation/imports/test.d(2)`.unifyDirSep(`\`)
== `fail_compilation\test.d(1) Error: at fail_compilation\imports\test.d(2)`);
assert(`fail_compilation/diag.d(2): Error: fail_compilation/imports/fail.d must be imported`.unifyDirSep(`\`)
== `fail_compilation\diag.d(2): Error: fail_compilation\imports\fail.d must be imported`);
assert(`{{RESULTS_DIR}}/fail_compilation/mixin_test.mixin(7): Error:`.unifyDirSep(`\`)
== `{{RESULTS_DIR}}\fail_compilation\mixin_test.mixin(7): Error:`);
assert(`{{RESULTS_DIR}}/fail_compilation/mixin_test.d-mixin-50(7): Error:`.unifyDirSep(`\`)
== `{{RESULTS_DIR}}\fail_compilation\mixin_test.d-mixin-50(7): Error:`);
assert("runnable\\xtest46_gc.d-mixin-37(187): Error".unifyDirSep("/") == "runnable/xtest46_gc.d-mixin-37(187): Error");
// optional columns
assert(`{{RESULTS_DIR}}/fail_compilation/cols.d(12,7): Error:`.unifyDirSep(`\`)
== `{{RESULTS_DIR}}\fail_compilation\cols.d(12,7): Error:`);
// gnu style
assert(`fail_compilation/test.d:1: Error: dummy error message for 'test'`.unifyDirSep(`\`)
== `fail_compilation\test.d:1: Error: dummy error message for 'test'`);
// in quotes as well
assert("'imports\\foo.d'".unifyDirSep("/") == "'imports/foo.d'");
assert("`imports\\foo.d`".unifyDirSep("/") == "`imports/foo.d`");
assert("\"imports\\foo.d\"".unifyDirSep("/") == "\"imports/foo.d\"");
assert("fail_compilation\\foo.d: Error:".unifyDirSep("/") == "fail_compilation/foo.d: Error:");
// at the end of a sentence
assert("fail_compilation\\foo.d. A".unifyDirSep("/") == "fail_compilation/foo.d. A");
assert("fail_compilation\\foo.d(2). A".unifyDirSep("/") == "fail_compilation/foo.d(2). A");
assert("fail_compilation\\foo.d, A".unifyDirSep("/") == "fail_compilation/foo.d, A");
assert("fail_compilation\\foo.d(2), A".unifyDirSep("/") == "fail_compilation/foo.d(2), A");
assert("fail_compilation\\foo.d".unifyDirSep("/") == "fail_compilation/foo.d");
assert("fail_compilation\\foo.d\n".unifyDirSep("/") == "fail_compilation/foo.d\n");
assert("fail_compilation\\foo.d\r\n".unifyDirSep("/") == "fail_compilation/foo.d\r\n");
assert("\nfail_compilation\\foo.d".unifyDirSep("/") == "\nfail_compilation/foo.d");
assert("\r\nfail_compilation\\foo.d".unifyDirSep("/") == "\r\nfail_compilation/foo.d");
assert("fail_compilation\\imports\\cfoo.c. A".unifyDirSep("/") == "fail_compilation/imports/cfoo.c. A");
assert(("runnable\\xtest46_gc.d-mixin-37(220): Deprecation: `opDot` is deprecated. Use `alias this`\n"~
"runnable\\xtest46_gc.d-mixin-37(222): Deprecation: `opDot` is deprecated. Use `alias this`").unifyDirSep("/") ==
"runnable/xtest46_gc.d-mixin-37(220): Deprecation: `opDot` is deprecated. Use `alias this`\n"~
"runnable/xtest46_gc.d-mixin-37(222): Deprecation: `opDot` is deprecated. Use `alias this`");
assert("".unifyDirSep("/") == "");
assert(" \n ".unifyDirSep("/") == " \n ");
assert("runnable/xtest46_gc.d-mixin-$n$(222): ".unifyDirSep("\\") ==
"runnable\\xtest46_gc.d-mixin-$n$(222): ");
assert(`S('\xff').this(1)`.unifyDirSep("/") == `S('\xff').this(1)`);
assert(`invalid UTF character \U80000000`.unifyDirSep("/") == `invalid UTF character \U80000000`);
assert("https://code.dlang.org".unifyDirSep("\\") == "https://code.dlang.org");
}
/**
* Compiles all non-D sources using their respective compiler and flags
* and appends the generated objects to `sources`.
*
* Params:
* input_dir = test directory (e.g. `runnable`)
* output_dir = directory for intermediate files
* extraSources = sources to compile
* sources = list of D sources to extend with object files
* envData = environment configuration
* compiler = external compiler (E.g. clang)
* cxxflags = external compiler flags
* logfile = the logfile
*
* Returns: false if a compilation error occurred
*/
bool collectExtraSources (in string input_dir, in string output_dir, in string[] extraSources,
ref string[] sources, in EnvData envData, in string ccompiler,
in string cxxcompiler, const(char)[] cxxflags, ref File logfile)
{
foreach (cur; extraSources)
{
auto curSrc = input_dir ~ envData.sep ~"extra-files" ~ envData.sep ~ cur;
auto curObj = output_dir ~ envData.sep ~ cur ~ envData.obj;
bool is_cpp_file = cur.extension() == ".cpp";
string command = quoteSpaces(is_cpp_file ? cxxcompiler : ccompiler);
if (envData.os == "windows") // cl.exe
{
command ~= ` /c /nologo `~curSrc~` /Fo`~curObj;
}
else
{
command ~= " -m"~envData.model~" -c "~curSrc~" -o "~curObj;
}
if (cxxflags)
command ~= " " ~ cxxflags;
logfile.writeln(command);
logfile.flush(); // Avoid reordering due to buffering
auto pid = spawnShell(command, stdin, logfile, logfile, null, Config.retainStdout | Config.retainStderr);
if(wait(pid))
{
return false;
}
sources ~= curObj;
}
return true;
}
/++
Applies custom transformations defined in transformOutput to testOutput.
Currently the following actions are supported:
* "sanitize_json" = replace compiler/plattform specific data from generated JSON
* "sanitize_timetrace" = parse -ftime-trace profiler output, and only extract event names
* "remove_lines(<re>)" = remove all lines matching a regex <re>
Params:
testOutput = the existing output to be modified
transformOutput = list of transformation identifiers
++/
void applyOutputTransformations(ref string testOutput, string transformOutput)
{
while (transformOutput.length)
{
string step, arg;
const idx = transformOutput.countUntil(' ', '(');
if (idx == -1)
{
step = transformOutput;
transformOutput = null;
}
else
{
step = transformOutput[0 .. idx];
const hasArgs = transformOutput[idx] == '(';
transformOutput = transformOutput[idx + 1 .. $];
if (hasArgs)
{
// "..." quotes are optional but necessary if the arg contains ')'
const isQuoted = transformOutput[0] == '"';
const end = isQuoted ? `"` : `)`;
auto parts = transformOutput[isQuoted .. $].findSplit(end);
enforce(parts, "Missing closing `" ~ end ~ "`!");
arg = parts[0];
transformOutput = parts[2][isQuoted .. $];
}
// Skip space between steps
import std.ascii : isWhite;
transformOutput.skipOver!isWhite();
}
switch (step)
{
case "sanitize_json":
{
import sanitize_json : sanitize;
sanitize(testOutput);
break;
}
case "remove_lines":
{
auto re = regex(arg);
testOutput = testOutput
.splitter('\n')
.filter!(line => !line.matchFirst(re))
.join('\n');
break;
}
case "sanitize_timetrace":
import sanitize_timetrace;
sanitizeTimeTrace(testOutput);
break;
default:
throw new Exception(format(`Unknown transformation: "%s"!`, step));
}
}
}
unittest
{
static void test(string input, const string transformations, const string expected)
{
applyOutputTransformations(input, transformations);
assert(input == expected);
}
static void testJson(const string transformations, const string expectedJson)
{
test(`{
"modules": [
{
"file": "/path/to/the/file",
"kind": "module",
"members": []
}
]
}`, transformations, expectedJson);
}
testJson("sanitize_json", `{
"modules": [
{
"file": "VALUE_REMOVED_FOR_TEST",
"kind": "module",
"members": []
}
]
}`);
testJson(`sanitize_json remove_lines("kind")`, `{
"modules": [
{
"file": "VALUE_REMOVED_FOR_TEST",
"members": []
}
]
}`);
testJson(`sanitize_json remove_lines("kind") remove_lines("file")`, `{
"modules": [
{
"members": []
}
]
}`);
test(`This is a text containing
some words which is a text sample
nevertheless`,
`remove_lines(text sample)`,
`This is a text containing
nevertheless`);
test(`This is a text with
a random ) which should
still work`,
`remove_lines("random \)")`,
`This is a text with
still work`);
test(`Tom bought
12 apples
and 6 berries
from the store`,
`remove_lines("(\d+)")`,
`Tom bought
from the store`);
assertThrown(test("", "unknown", ""));
}
/// List of supported special sequences used in compareOutput
alias specialSequences = AliasSeq!("$n$", "$p:", "$r:", "$?:");
/++
Compares the output string to the reference string by character
except parts marked with one of the following special sequences:
$n$ = numbers (e.g. compiler generated unique identifiers)
$p:<path>$ = real paths ending with <path>
$?:<choices>$ = environment dependent content supplied as a list
choices (either <condition>=<content> or <default>),
separated by a '|'. Currently supported conditions are
OS and model as supplied from the environment
$r:<regex>$ = text matching <regex> (using $ inside of regex is not
supported, use multiple regexes instead)
Params:
output = the real output
refoutput = the expected output
envData = test environment
Returns: whether output matches the expected refoutput
++/
bool compareOutput(string output, string refoutput, const ref EnvData envData)
{
// If no output is expected, only check that nothing was captured.
if (refoutput.length == 0)
return (output.length == 0) ? true : false;
for ( ; ; )
{
auto special = refoutput.find(specialSequences).rename!("remainder", "id");
// Simple equality check if no special tokens remain
if (special.id == 0)
return refoutput == output;
const expected = refoutput[0 .. $ - special.remainder.length];
// Check until the special token
if (!output.skipOver(expected))
return false;
// Discard the special token and progress output appropriately
refoutput = special.remainder[3 .. $];
if (special.id == 1) // $n$
{
import std.ascii : isDigit;
output.skipOver!isDigit();
continue;
}
// $<identifier>:<special content>$
/// ( special content, "$", remaining expected output )
auto refparts = refoutput.findSplit("$");
enforce(refparts, "Malformed special sequence!");
refoutput = refparts[2];
if (special.id == 2) // $p:<some path>$
{
// special content is the expected path tail
// Substitute / with the appropriate directory separator
const pathTail = refparts[0].replace("/", envData.sep);
const newlineIndex = output.indexOf('\n');
const outputLine = newlineIndex == -1 ? output : output[0 .. newlineIndex];
const path = outputLine.findLastSplitAfter(pathTail)[0];
if (path.empty || !exists(path))
return false;
output = output[path.length .. $];
continue;
}
else if (special.id == 3) // $r:<regex>$
{
// need some context behind this expression to stop the regex match
// e.g. "$r:.*$ failed with..." uses " failed"
auto context = refoutput[0 .. min(7, $)];
const parts = context.findSplitBefore("$");
// Avoid collisions with other special sequences
if (!parts[1].empty)
{
context = parts[0];
enforce(context.length, "Another sequence following $r:...$ is not supported!");
}
// Remove the context from the remaining expected output
refoutput = refoutput[context.length .. $];
// Use '^' to match <regex><context> at the beginning of output
auto re = regex('^' ~ refparts[0] ~ context, "s");
auto match = output.matchFirst(re);
if (!match)
return false;
output = output[match.front.length .. $];
continue;
}
// $?:<predicate>=<content>(;<predicate>=<content>)*(;<default>)?$
string toSkip = null;
foreach (const chunk; refparts[0].splitter('|'))
{
// ( <predicate> , "=", <content> )
const searchResult = chunk.findSplit("=");
if (searchResult[1].empty) // <default>
{
toSkip = chunk;
break;
}
// Match against OS or model
else if (searchResult[0].splitter('+').all!(c => c.among(envData.os, envData.model)))
{
toSkip = searchResult[2];
break;
}
}
if (toSkip !is null && !output.skipOver(toSkip))
return false;
}
}
private string[2] findLastSplitAfter(in string haystack, in string needle)
{
string[2] r = [null, haystack];
foreach_reverse (end; needle.length .. haystack.length + 1) // include haystack.length
{
const candidateTail = haystack[end - needle.length .. end];
if (candidateTail == needle)
{
r[0] = haystack[0 .. end];
r[1] = haystack[end .. $];
break;
}
}
return r;
}
unittest
{
assert("abc".findLastSplitAfter("abcd") == ["", "abc"]);
assert("abc".findLastSplitAfter("abc") == ["abc", ""]);
assert("abc".findLastSplitAfter("ab") == ["ab", "c"]);
assert("/phobos/bla/phobos/blub".findLastSplitAfter("phobos") == ["/phobos/bla/phobos", "/blub"]);
}
unittest
{
EnvData ed;
version (Windows)
ed.sep = `\`;
else
ed.sep = `/`;
assert( compareOutput(`Grass is green`, `Grass is green`, ed));
assert(!compareOutput(`Grass is green`, `Grass was green`, ed));
assert( compareOutput(`Bob took 12 apples`, `Bob took $n$ apples`, ed));
assert(!compareOutput(`Bob took abc apples`, `Bob took $n$ apples`, ed));
assert(!compareOutput(`Bob took 12 berries`, `Bob took $n$ apples`, ed));
assert( compareOutput(`HINT: ` ~ __FILE_FULL_PATH__ ~ ` is important`, `HINT: $p:d_do_test.d$ is important`, ed));
assert( compareOutput(`HINT: ` ~ __FILE_FULL_PATH__ ~ ` is important`, `HINT: $p:test/tools/d_do_test.d$ is important`, ed));
ed.sep = "/";
assert(!compareOutput(`See /path/to/druntime/import/object.d`, `See $p:druntime/import/object.d$`, ed));
assertThrown(compareOutput(`Path /a/b/c.d!`, `Path $p:c.d!`, ed)); // Missing closing $
const fmt = "This $?:windows=A|posix=B|C$ uses $?:64=1|32=2|3$ bytes";
assert( compareOutput("This C uses 3 bytes", fmt, ed));
ed.os = "posix";
ed.model = "64";
assert( compareOutput("This B uses 1 bytes", fmt, ed));
assert(!compareOutput("This C uses 3 bytes", fmt, ed));
const emptyFmt = "On <$?:windows=abc|$> use <$?:posix=$>!";
assert(compareOutput("On <> use <>!", emptyFmt, ed));
ed.model = "32";
assert(compareOutput("size_t is uint!", "size_t is $?:32=uint|64=ulong$!", ed));
assert(compareOutput("no", "$?:posix+64=yes|no$", ed));
ed.model = "64";
assert(compareOutput("yes", "$?:posix+64=yes|no$", ed));
assert(compareOutput("This number 12", `This $r:\w+ \d+$`, ed));
assert(compareOutput("This number 12", `This $r:\w+ (\d)+$`, ed));
assert(compareOutput("This number 12 is nice", `This $r:.*$ 12 is nice`, ed));
assert(compareOutput("This number 12", `This $r:.*$ 12`, ed));
assert(!compareOutput("This number 12 is 24", `This $r:\d*$ 12`, ed));
assert(compareOutput("This number 12 is 24", `This $r:.*$ 12 is $n$`, ed));
string msg = collectExceptionMsg(compareOutput("12345", `$r:\d*$$n$`, ed));
assert(msg == "Another sequence following $r:...$ is not supported!");
}
/++
Creates a diff of the expected and actual test output.
Params:
expected = the expected output
expectedFile = file containing expected (if present, null otherwise)
actual = the actual output
name = the test files name
Returns: the comparison created by the `diff` utility
++/
string generateDiff(const string expected, string expectedFile,
const string actual, const string name)
{
string actualFile = tempDir.buildPath("actual_" ~ name);
File(actualFile, "w").writeln(actual); // Append \n
scope (exit) tryRemove(actualFile);
const needTmp = !expectedFile;
if (needTmp) // Create a temporary file
{
expectedFile = tempDir.buildPath("expected_" ~ name);
File(expectedFile, "w").writeln(expected); // Append \n
}
// Remove temporary file
scope (exit) if (needTmp)
tryRemove(expectedFile);
const cmd = ["diff", "-up", "--strip-trailing-cr", expectedFile, actualFile];
try
{
string diff = std.process.execute(cmd).output;
// Skip diff's status lines listing the diffed files and line count
foreach (_; 0..3)
diff = diff.findSplitAfter("\n")[1];
return diff;
}
catch (Exception e)
return format(`%-(%s, %) failed: %s`, cmd, e.msg);
}
/**
* Exception thrown to abort the test without further error messages
* (they were either already printed or suppressed due to the environment)
*/
class SilentQuit : Exception { this() { super(null); } }
/**
* Exception thrown when the actual output doesn't match the expected
* `TEST_OUTPUT`/`RUN_OUTPUT.`
*/
class CompareException : Exception
{
string expected; /// expected output
string actual; /// actual output
bool fromRun; /// Compared execution instead of compilation output
string diff; /// diff between expected and actual output
this(string expected, string actual, string diff, bool fromRun = false) {
string msg = "\nexpected:\n----\n" ~ expected ~
"\n----\nactual:\n----\n" ~ actual ~
"\n----\ndiff:\n----\n" ~ diff ~ "----\n";
super(msg);
this.expected = expected;
this.actual = actual;
this.fromRun = fromRun;
this.diff = diff;
}
}
/// Return code indicating that the test should be restarted.
/// Issued when an OUTPUT section was changed due to AUTO_UPDATE=1.
enum RERUN_TEST = 2;
version(unittest) void main(){} else
int main(string[] args)
{
try
{
// Test may be run multiple times with AUTO_UPDATE=1 because updates
// to output sections may change line numbers.
// Set a hard limit to avoid infinite loops in fringe cases
foreach (_; 0 .. 10)
{
const res = tryMain(args);
if (res == RERUN_TEST)
writeln("==> Restarting test to verify new output section(s)...\n");
else
return res;
}
// Should never happen, but just to be sure
writeln("Output sections changed too many times, please update manually.");
return RERUN_TEST;
}
catch(SilentQuit) { return 1; }
}
int tryMain(string[] args)
{
if (args.length != 2)
{
usage();
return 1;
}
immutable envData = processEnvironment();
const input_file = args[1];
const input_dir = input_file.dirName();
const test_base_name = input_file.baseName();
const test_name = test_base_name.stripExtension();
const test_ext = test_base_name.extension();
// Previously we did not take into account the test file extension,
// because of ImportC we now have the ability to have conflicting source file basenames (when ignoring extension).
// This can cause a race condition with the concurrent test runner,
// in which whilst one test is running another will try to clobber it and fail.
// Unfortunately dshell does not take into account our output directory,
// at least not in a way that'll allow us to take advantage of the test extension.
// However since its not really an issue for clobbering, we'll ignore it.
const result_path = envData.results_dir ~ envData.sep;
const output_dir_base = result_path ~ input_dir;
const output_dir = (input_dir == "dshell" ? output_dir_base : (output_dir_base ~ envData.sep ~ test_ext[1 .. $]));
const output_file = result_path ~ input_file ~ ".out";
// We are potentially creating a test extension specific directory
mkdirRecurse(output_dir);
TestArgs testArgs;
switch (input_dir)
{
case "compilable": testArgs.mode = TestMode.COMPILE; break;
case "fail_compilation": testArgs.mode = TestMode.FAIL_COMPILE; break;
case "runnable", "runnable_cxx":
// running & linking costs time - for coverage builds we can save this
testArgs.mode = envData.coverage_build ? TestMode.COMPILE : TestMode.RUN;
break;
case "dshell":
testArgs.mode = TestMode.DSHELL;
return runDShellTest(input_dir, test_name, envData, output_dir, output_file);
default:
writefln("Error: invalid test directory '%s', expected 'compilable', 'fail_compilation', 'runnable', 'runnable_cxx' or 'dshell'", input_dir);
return 1;
}
if (test_ext == ".sh")
{
string file = cast(string) std.file.read(input_file);
string disabledPlatforms;
if (findTestParameter(envData, file, "DISABLED", disabledPlatforms))
{
const reason = getDisabledReason(split(disabledPlatforms), envData);
if (reason.length != 0)
{
writefln(" ... %-30s [DISABLED %s]", input_file, reason);
return 0;
}
}
version (linux)
{
if (file.canFind("GDB_SCRIPT"))
{
return runGDBTestWithLock(envData, () {
return runBashTest(input_dir, test_name, envData);
});
}
}
return runBashTest(input_dir, test_name, envData);
}
// envData.sep is required as the results_dir path can be `generated`
const absoluteResultDirPath = envData.results_dir.absolutePath ~ envData.sep;
const resultsDirReplacement = "{{RESULTS_DIR}}" ~ envData.sep;
const test_app_dmd_base = output_dir ~ envData.sep ~ test_name ~ "_";
auto stopWatch = StopWatch(AutoStart.no);
if (envData.printRuntime)
stopWatch.start();
if (!gatherTestParameters(testArgs, input_dir, input_file, envData))
return 0;
// Clear the DFLAGS environment variable if it was specified in the test file
if (testArgs.clearDflags)
environment["DFLAGS"] = "";
writef(" ... %-30s %s%s(%s)",
input_file,
testArgs.requiredArgs,
(!testArgs.requiredArgs.empty ? " " : ""),
testArgs.permuteArgs);
if (testArgs.isDisabled)
{
writef("!!! [DISABLED %s]", testArgs.disabledReason);
if (!envData.tryDisabled)
{
writeln();
return 0;
}
}
auto f = File(output_file, "w");
if (
//prepare cpp extra sources
!collectExtraSources(input_dir, output_dir, testArgs.cppSources, testArgs.sources, envData, envData.ccompiler, envData.cxxcompiler, testArgs.cxxflags, f) ||
//prepare objc extra sources
!collectExtraSources(input_dir, output_dir, testArgs.objcSources, testArgs.sources, envData, "clang", "clang++", null, f)
)
{
writeln();
// Ignore failed test
if (testArgs.isDisabled)
return 0;
printTestFailure(input_file, f);
return 1;
}
enum Result { continue_, return0, return1, returnRerun }
// Runs the test with a specific combination of arguments
Result testCombination(bool autoCompileImports, string argSet, size_t permuteIndex, string permutedArgs)
{
string test_app_dmd = test_app_dmd_base ~ to!string(permuteIndex) ~ envData.exe;
string command; // copy of the last executed command so that it can be re-invoked on failures
try
{
string[] toCleanup;
auto thisRunName = output_file ~ to!string(permuteIndex);
auto fThisRun = File(thisRunName, "w");
scope(exit)
{
fThisRun.close();
f.write(readText(thisRunName));
f.writeln();
tryRemove(thisRunName); // Never reached unless file is present
}
string compile_output;
if (!testArgs.compileSeparately)
{
string objfile = output_dir ~ envData.sep ~ test_name ~ "_" ~ to!string(permuteIndex) ~ envData.obj;
toCleanup ~= objfile;
command = format("%s -conf= -m%s -I%s %s %s -od%s -of%s %s %s%s %s", envData.dmd, envData.model, input_dir,
testArgs.requiredArgs, permutedArgs, output_dir,
(testArgs.mode == TestMode.RUN || testArgs.link ? test_app_dmd : objfile),
argSet,
(testArgs.mode == TestMode.RUN || testArgs.link ? "" : "-c "),
join(testArgs.sources, " "),
(autoCompileImports ? "-i" : join(testArgs.compiledImports, " ")));
try
compile_output = execute(fThisRun, command, testArgs.mode == TestMode.FAIL_COMPILE);
catch (Exception e)
{
writeln(""); // We're at "... runnable/xxxx.d (args)"
printCppSources(testArgs.sources);
throw e;
}
}
else
{
foreach (filename; testArgs.sources ~ (autoCompileImports ? null : testArgs.compiledImports))
{
string newo = output_dir ~ envData.sep ~ filename.baseName().setExtension(envData.obj);
toCleanup ~= newo;
command = format("%s -conf= -m%s -I%s %s %s -od%s -c %s %s", envData.dmd, envData.model, input_dir,
testArgs.requiredArgs, permutedArgs, output_dir, argSet, filename);
compile_output ~= execute(fThisRun, command, testArgs.mode == TestMode.FAIL_COMPILE);
}
if (testArgs.mode == TestMode.RUN || testArgs.link)
{
// link .o's into an executable
command = format("%s -conf= -m%s%s%s %s %s -od%s -of%s %s", envData.dmd, envData.model,
autoCompileImports ? " -i" : "",
autoCompileImports ? "extraSourceIncludePaths" : "",
envData.required_args, testArgs.requiredArgsForLink, output_dir, test_app_dmd, join(toCleanup, " "));
execute(fThisRun, command, testArgs.runReturn);
}
}
void prepare(ref string compile_output)
{
if (compile_output.empty)
return;
compile_output = compile_output.unifyNewLine();
compile_output = std.regex.replaceAll(compile_output, regex(`^DMD v2\.[0-9]+.*\n? DEBUG$`, "m"), "");
compile_output = std.string.strip(compile_output);
// replace test_result path with fixed ones
compile_output = compile_output.replace(result_path, resultsDirReplacement);
compile_output = compile_output.replace(absoluteResultDirPath, resultsDirReplacement);
compile_output.applyOutputTransformations(testArgs.transformOutput);
}
prepare(compile_output);
auto m = std.regex.match(compile_output, `Internal error: .*$`);
enforce(!m, m.hit);
m = std.regex.match(compile_output, `core.exception.AssertError@dmd.*`);
enforce(!m, m.hit);
// Prepare and append the content of each OUTPUT_FILE conforming to
// the HAR (https://code.dlang.org/packages/har) format, e.g.
// === <FILENAME_1>
// <CONTENT_1>
// === <FILENAME_2>
// <CONTENT_2>
// ...
foreach (const outfile; testArgs.outputFiles)
{
string path = outfile;
replaceResultsDir(path, envData);
// Don't abort if a file is missing, at least verify the remaining output.
string content = readText(path).ifThrown("<< File missing >>");
prepare(content);
// Make sure file starts on a new line
if (!compile_output.empty && !compile_output.endsWith("\n"))
compile_output ~= '\n';
// Prepend a header listing the explicit file
compile_output.reserve(outfile.length + content.length + 5);
compile_output ~= "=== ";
compile_output ~= outfile;
compile_output ~= '\n';
compile_output ~= content;
}
if (!compareOutput(compile_output, testArgs.compileOutput, envData))
{
const diff = generateDiff(testArgs.compileOutput, testArgs.compileOutputFile,
compile_output, test_base_name);
throw new CompareException(testArgs.compileOutput, compile_output, diff);
}
if (testArgs.mode == TestMode.RUN)
{
toCleanup ~= test_app_dmd;
version(Windows)
{
toCleanup ~= test_app_dmd_base ~ to!string(permuteIndex) ~ ".ilk";
toCleanup ~= test_app_dmd_base ~ to!string(permuteIndex) ~ ".pdb";
}
if (testArgs.gdbScript is null)
{
command = test_app_dmd;
if (testArgs.executeArgs) command ~= " " ~ testArgs.executeArgs;
string output = execute(fThisRun, command, testArgs.runReturn)
.strip()
.unifyNewLine();
output.applyOutputTransformations(testArgs.transformOutput);
if (testArgs.runOutput && !compareOutput(output, testArgs.runOutput, envData))
{
const diff = generateDiff(testArgs.runOutput, null, output, test_base_name);
throw new CompareException(testArgs.runOutput, output, diff, true);
}
}
else version (linux)
{
runGDBTestWithLock(envData, () {
auto script = test_app_dmd_base ~ to!string(permuteIndex) ~ ".gdb";
toCleanup ~= script;
with (File(script, "w"))
{
writeln("set disable-randomization off");
write(testArgs.gdbScript);
}
string gdbCommand = "gdb "~test_app_dmd~" --batch -x "~script;
auto gdb_output = execute(fThisRun, gdbCommand, 0);
if (testArgs.gdbMatch !is null)
{
enforce(match(gdb_output, regex(testArgs.gdbMatch)),
"\nGDB regex: '"~testArgs.gdbMatch~"' didn't match output:\n----\n"~gdb_output~"\n----\n");
}
return 0;
});
}
}
fThisRun.close();
if (testArgs.postScript && !envData.coverage_build)
{
f.write("Executing post-test script: ");
string prefix = "";
version (Windows) prefix = "bash ";
execute(f, prefix ~ "tools/postscript.sh " ~ testArgs.postScript ~ " " ~ input_dir ~ " " ~ test_name ~ " " ~ thisRunName, 0);
}
foreach (file; chain(toCleanup, testArgs.outputFiles))
tryRemove(file);
return Result.continue_;
}
catch(Exception e)
{
// it failed but it was disabled, exit as if it was successful
if (testArgs.isDisabled)
{
writeln();
return Result.return0;
}
if (envData.autoUpdate)
if (auto ce = cast(CompareException) e)
{
// remove the output file in test_results as its outdated
// (might fail for runnable tests on Windows)
if (output_file.remove().collectException())
writef("\nWARNING: Failed to remove `%s`!", output_file);
// Don't overwrite TEST_OUTPUT sections which contain special
// sequences because they must be manually adapted
if (testArgs.compileOutput.canFind(specialSequences))
{
writefln("\nWARNING: %s uses special sequences in `TEST_OUTPUT` blocks and can't be auto-updated", input_file);
return Result.return0;
}
if (testArgs.compileOutputFile && !ce.fromRun)
{
std.file.write(testArgs.compileOutputFile, ce.actual);
writefln("\n==> `TEST_OUTPUT_FILE` `%s` has been updated", testArgs.compileOutputFile);
return Result.returnRerun;
}
auto existingText = input_file.readText;
auto updatedText = existingText.replace(ce.expected, ce.actual);
const type = ce.fromRun ? `RUN`: `TEST`;
if (existingText != updatedText)
{
std.file.write(input_file, updatedText);
writefln("\n==> `%s_OUTPUT` of %s has been updated", type, input_file);
return Result.returnRerun;
}
else
{
try
{
string diffUpdatedText = replaceFromDiff(existingText, ce.diff);
std.file.write(input_file, diffUpdatedText);
writefln("\n==> `%s_OUTPUT` of %s has been updated by applying a diff", type, input_file);
return Result.returnRerun;
}
catch (Exception e)
{
writefln("\nERROR: Couldn't update `%s_OUTPUT` blocks of %s through the diff: \"%s\"
Please update the file manually to make the tests pass.", type, input_file, e.msg);
}
return Result.return0;
}
}
const outputText = printTestFailure(input_file, f, e.msg);
// auto-update if a diff is found and can be updated
if (envData.autoUpdate &&
outputText.canFind("diff ") && outputText.canFind("--- ") && outputText.canFind("+++ "))
{
import std.range : dropOne;
auto newFile = outputText.findSplitAfter("+++ ")[1].until("\t");
auto baseFile = outputText.findSplitAfter("--- ")[1].until("\t");
writefln("===> Updating %s with %s", baseFile, newFile);
newFile.copy(baseFile);
return Result.return0;
}
// automatically rerun a segfaulting test and print its stack trace
version(linux)
if (e.msg.canFind("exited with rc == 139"))
{
auto gdbCommand = "gdb -q -n -ex 'set backtrace limit 100' -ex run -ex bt -batch -args " ~ command;
runGDBTestWithLock(envData, () => spawnShell(gdbCommand).wait);
}
return Result.return1;
}
}
size_t index = 0; // index over all tests to avoid identical output names in consecutive tests
auto argSets = (testArgs.argSets.length == 0) ? [""] : testArgs.argSets;
for(auto autoCompileImports = false;; autoCompileImports = true)
{
foreach(argSet; argSets)
{
foreach (c; combinations(testArgs.permuteArgs))
{
final switch(testCombination(autoCompileImports, argSet, index, c))
{
case Result.continue_: break;
case Result.return0: return 0;
case Result.return1: return 1;
case Result.returnRerun: return RERUN_TEST;
}
index++;
}
}
if(autoCompileImports || testArgs.compiledImports.length == 0)
break;
}
if (envData.printRuntime)
{
const long ms = stopWatch.peek.total!"msecs";
writefln(" [%.3f secs]", ms / 1000.0);
}
else
writeln();
// it was disabled but it passed! print an informational message
if (testArgs.isDisabled)
writefln(" !!! %-30s DISABLED but PASSES!", input_file);
return 0;
}
// Replace consecutive ---+++ diff lines with intertwined lines -+-+-+, which helps putting
// additions in the right TEST_OUTPUT block. Otherwise, sometimes all but the last TEST_OUTPUT blocks
// are emptied and the last TEST_OUTPUT block will be filled will all updated output.
string intertwineDiff(string diff)
{
static if (__VERSION__ < 2097)
{
// `splitWhen` didn't exist, but the bootstrap compiler test doesn't need AUTO_UPDATE
return diff;
}
else
{
// First, split diff lines into groups of deletions (-), additions (+), or other (@, ' ')
auto editGroups = diff.splitter('\n').chunkBy!((a, b) => a.takeOne.equal(b.takeOne)).map!array;
// Then split before every deletion (-) group
auto deletionGroups = editGroups.splitWhen!((a, b) => b.front.startsWith("-")).map!array;
// Then, if we have a deletion group followed by an addition group, roundRobin the first two editGroups, and append the rest
// Otherwise, just join all editGroups to keep the original order
return deletionGroups.map!(g => choose(
g.length > 1 && g[0].front.startsWith("-") && g[1].front.startsWith("+"),
g.length > 1 ? chain(roundRobin(g[0], g[1]), g[2 .. $].join).array : g.join.array,
g.join
)).join.join("\n");
}
}
unittest
{
string input = "@@@
-A0
-B1
+E2
+F3
+H4
-32
+33
+34
35
36
-C5";
string expected = "@@@
-A0
+E2
-B1
+F3
+H4
-32
+33
+34
35
36
-C5";
assert(intertwineDiff("") == "");
static if (__VERSION__ >= 2097)
assert(intertwineDiff(input) == expected);
}
/// Given test file with contents `input` and diff file with the diff of actual TEST_OUTPUT vs expected TEST_OUTPUT,
/// return new contents of the test file with updated TEST_OUTPUT blocks. Throws an Exception if the diff couldn't be
/// matched against the input.
string replaceFromDiff(string input, string diff)
{
const string[] lines = input.splitLines;
string result = "";
size_t i = 0;
foreach (diffLine; intertwineDiff(diff).splitLines)
{
const bool deletion = diffLine.skipOver("-");
const bool seek = deletion || diffLine.skipOver(" ");
if (seek)
{
while (i < lines.length && lines[i] != diffLine)
{
result ~= lines[i] ~ "\n";
i++;
}
if (i >= lines.length)
throw new Exception("Can't find diff line \"" ~ diffLine ~ "\" in the text to update");
if (!deletion)
result ~= lines[i] ~ "\n";
i++;
}
else if (diffLine.skipOver("+"))
{
result ~= diffLine ~ "\n";
continue;
}
else if (diffLine.skipOver("@"))
{
continue;
}
else
{
throw new Exception("Unrecognized first character in diff line: \"" ~ diffLine ~ "\"");
}
}
while (i < lines.length)
{
result ~= lines[i] ~ "\n";
i++;
}
return result;
}
unittest
{
string input = "
TEST_OUTPUT:
---
Error: dummy
---
TEST_OUTPUT:
---
Error: something else
Deprecation: dummy
---
";
string diff =
"-Error: dummy
-Error: something else
+Error: dummies
@@@ ...
Deprecation: dummy
+Deprecation: another
";
string expected = "
TEST_OUTPUT:
---
Error: dummies
---
TEST_OUTPUT:
---
Deprecation: dummy
Deprecation: another
---
";
string result = replaceFromDiff(input, diff);
static if (__VERSION__ >= 2097)
assert(result == expected);
try
{
replaceFromDiff(input, "-Nonexistend line");
assert(0);
}
catch (Exception e)
{
assert(e.msg == `Can't find diff line "Nonexistend line" in the text to update`);
}
}
/**
* Executes a bash script (deprecated in favour of `dshell` tests).
*
* Params:
* input_dir = test directory (e.g. `runnable`)
* test_name = script filename
* envData = environment configuration
*
* Returns: the script's exit code
*/
int runBashTest(string input_dir, string test_name, const ref EnvData envData)
{
enum script = "tools/sh_do_test.sh";
version(Windows)
{
const cmd = "bash " ~ script ~ ' ' ~ input_dir ~ ' ' ~ test_name;
const env = [
// Make sure the path is bash-friendly
"DMD": envData.dmd.relativePath(dmdTestDir).replace("\\", "/")
];
auto process = spawnShell(cmd, env, Config.none, dmdTestDir);
}
else
{
const scriptPath = dmdTestDir.buildPath(script);
auto process = spawnProcess([scriptPath, input_dir, test_name]);
}
return process.wait();
}
/**
* Executes `fun` mutually exclusive to other instances of `d_do_test`
* using the lockfile `$RESULTS_DIR/gdb.lock`.
*
* Params:
* envData = environment configuration
* fun = task to execute
*
* Returns: the return value of `fun`
*/
int runGDBTestWithLock(const ref EnvData envData, int delegate() fun)
{
// Tests failed on SemaphoreCI when multiple GDB tests were run at once
scope lockfile = File(envData.results_dir.buildPath("gdb.lock"), "w");
lockfile.lock();
scope (exit) lockfile.unlock();
return fun();
}
/**
* Executes a `dshell` test.
*
* Params:
* input_dir = test directory (e.g. `runnable`)
* test_name = script filename
* envData = environment configuration
* output_dir = directory for intermediate files (usually `${RESULTS_DIR}/dshell`)
* output_file = logfile path
*
* Returns: the script's exit code (or dmd's exit code upon compilation failure)
*/
int runDShellTest(string input_dir, string test_name, const ref EnvData envData,
string output_dir, string output_file)
{
const testScriptDir = buildPath(dmdTestDir, input_dir);
const testScriptPath = buildPath(testScriptDir, test_name ~ ".d");
const testOutDir = buildPath(output_dir, test_name);
const testLogName = format("%s/%s.d", input_dir, test_name);
writefln(" ... %s", testLogName);
if (exists(testOutDir))
rmdirRecurse(testOutDir);
mkdirRecurse(testOutDir);
// create the "dshell" module for the tests
{
auto dshellFile = File(buildPath(testOutDir, "dshell.d"), "w");
dshellFile.writeln(`module dshell;
public import dshell_prebuilt;
static this()
{
dshellPrebuiltInit("` ~ input_dir ~ `", "`, test_name , `");
}
`);
}
const testScriptExe = buildPath(testOutDir, "run" ~ envData.exe);
auto outfile = File(output_file, "w");
enum keepFilesOpen = Config.retainStdout | Config.retainStderr;
//
// compile the test
//
{
const compile = [envData.dmd, "-conf=", "-m"~envData.model] ~
envData.picFlag ~ [
"-od" ~ testOutDir,
"-of" ~ testScriptExe,
"-I=" ~ testScriptDir,
"-I=" ~ testOutDir,
"-I=" ~ buildPath(dmdTestDir, "tools", "dshell_prebuilt"),
"-i",
// Causing linker errors for some reason?
"-i=-dshell_prebuilt", buildPath(envData.results_dir, "dshell_prebuilt" ~ envData.obj),
testScriptPath,
];
outfile.writeln("[COMPILE_TEST] ", escapeShellCommand(compile));
outfile.flush();
// Note that spawnprocess closes the file, so it will need to be re-opened
// below when we run the test
auto compileProc = std.process.spawnProcess(compile, stdin, outfile, outfile, null, keepFilesOpen);
const exitCode = wait(compileProc);
if (exitCode != 0)
{
printTestFailure(testLogName, outfile);
return exitCode;
}
}
//
// run the test
//
{
const runTest = [testScriptExe];
outfile.writeln("\n[RUN_TEST] ", escapeShellCommand(runTest));
outfile.flush();
auto runTestProc = std.process.spawnProcess(runTest, stdin, outfile, outfile, null, keepFilesOpen);
const exitCode = wait(runTestProc);
if (exitCode == 125) // = DISABLED from tools/dshell_prebuilt.d
{
writefln(" !!! %s is disabled!", testLogName);
return 0;
}
else if (exitCode != 0)
{
printTestFailure(testLogName, outfile);
return exitCode;
}
}
return 0;
}
/**
* Prints the summary of a test failure to stdout and removes the logfile.
*
* Params:
* testLogName = name of the test
* outfile = the logfile
* extra = supplemental error message
* Returns: the content of outfile
**/
string printTestFailure(string testLogName, scope ref File outfile, string extra = null)
{
const output_file_temp = outfile.name;
outfile.close();
writeln("==============================");
writefln("Test '%s' failed. The logged output:", testLogName);
const output = readText(output_file_temp);
write(output);
if (!output.endsWith("\n"))
writeln();
writeln("==============================");
if (extra)
writefln("Test '%s' failed: %s\n", testLogName, extra);
tryRemove(output_file_temp);
return output;
}
/**
* Print symbols in C++ objects
*
* If linking failed, we print the symbols present in the C++ object file being
* linked it. This is so that C++ `runnable` tests are easier to debug,
* as the CI machines can have different environment than the users,
* and it is generally painful to work with them when trying to support
* newer (C++11, C++14, C++17, etc...) features.
*/
void printCppSources (in const(char)[][] compiled)
{
version (Posix)
{
foreach (file; compiled)
{
if (!file.endsWith(".cpp.o"))
continue;
writeln("========== Symbols for C++ object file: ", file, " ==========");
std.process.spawnProcess(["nm", file]).wait();
}
}
}