2020-02-11 06:01:56 -05:00
|
|
|
// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
|
2020-03-15 12:58:59 -04:00
|
|
|
import { gray, green, italic, red, yellow } from "./colors.ts";
|
2020-03-08 08:09:22 -04:00
|
|
|
import { exit } from "./ops/os.ts";
|
2020-03-15 12:58:59 -04:00
|
|
|
import { Console, stringifyArgs } from "./web/console.ts";
|
|
|
|
import { stdout } from "./files.ts";
|
2020-04-01 04:47:23 -04:00
|
|
|
import { exposeForTest } from "./internals.ts";
|
2020-03-15 12:58:59 -04:00
|
|
|
import { TextEncoder } from "./web/text_encoding.ts";
|
2020-03-18 19:25:55 -04:00
|
|
|
import { metrics } from "./ops/runtime.ts";
|
|
|
|
import { resources } from "./ops/resources.ts";
|
|
|
|
import { assert } from "./util.ts";
|
2020-02-11 06:01:56 -05:00
|
|
|
|
2020-03-28 13:03:49 -04:00
|
|
|
const disabledConsole = new Console((): void => {});
|
2020-03-13 10:57:32 -04:00
|
|
|
|
2020-04-03 13:20:36 -04:00
|
|
|
function delay(n: number): Promise<void> {
|
|
|
|
return new Promise((resolve: () => void, _) => {
|
|
|
|
setTimeout(resolve, n);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2020-03-05 05:52:18 -05:00
|
|
|
function formatDuration(time = 0): string {
|
|
|
|
const timeStr = `(${time}ms)`;
|
|
|
|
return gray(italic(timeStr));
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
// Wrap test function in additional assertion that makes sure
|
2020-03-18 19:25:55 -04:00
|
|
|
// the test case does not leak async "ops" - ie. number of async
|
|
|
|
// completed ops after the test is the same as number of dispatched
|
|
|
|
// ops. Note that "unref" ops are ignored since in nature that are
|
|
|
|
// optional.
|
2020-04-01 04:47:23 -04:00
|
|
|
function assertOps(fn: () => void | Promise<void>): () => void | Promise<void> {
|
2020-03-18 19:25:55 -04:00
|
|
|
return async function asyncOpSanitizer(): Promise<void> {
|
|
|
|
const pre = metrics();
|
|
|
|
await fn();
|
2020-04-03 13:20:36 -04:00
|
|
|
// Defer until next event loop turn - that way timeouts and intervals
|
|
|
|
// cleared can actually be removed from resource table, otherwise
|
|
|
|
// false positives may occur (https://github.com/denoland/deno/issues/4591)
|
|
|
|
await delay(0);
|
2020-03-18 19:25:55 -04:00
|
|
|
const post = metrics();
|
|
|
|
// We're checking diff because one might spawn HTTP server in the background
|
|
|
|
// that will be a pending async op before test starts.
|
|
|
|
const dispatchedDiff = post.opsDispatchedAsync - pre.opsDispatchedAsync;
|
|
|
|
const completedDiff = post.opsCompletedAsync - pre.opsCompletedAsync;
|
|
|
|
assert(
|
|
|
|
dispatchedDiff === completedDiff,
|
|
|
|
`Test case is leaking async ops.
|
|
|
|
Before:
|
|
|
|
- dispatched: ${pre.opsDispatchedAsync}
|
|
|
|
- completed: ${pre.opsCompletedAsync}
|
2020-04-01 04:47:23 -04:00
|
|
|
After:
|
2020-03-18 19:25:55 -04:00
|
|
|
- dispatched: ${post.opsDispatchedAsync}
|
2020-04-24 18:07:25 -04:00
|
|
|
- completed: ${post.opsCompletedAsync}
|
2020-06-12 11:58:04 -04:00
|
|
|
|
|
|
|
Make sure to await all promises returned from Deno APIs before
|
2020-04-24 18:07:25 -04:00
|
|
|
finishing test case.`
|
2020-03-18 19:25:55 -04:00
|
|
|
);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
// Wrap test function in additional assertion that makes sure
|
2020-03-18 19:25:55 -04:00
|
|
|
// the test case does not "leak" resources - ie. resource table after
|
|
|
|
// the test has exactly the same contents as before the test.
|
2020-04-01 04:47:23 -04:00
|
|
|
function assertResources(
|
|
|
|
fn: () => void | Promise<void>
|
|
|
|
): () => void | Promise<void> {
|
2020-03-18 19:25:55 -04:00
|
|
|
return async function resourceSanitizer(): Promise<void> {
|
|
|
|
const pre = resources();
|
|
|
|
await fn();
|
|
|
|
const post = resources();
|
|
|
|
|
|
|
|
const preStr = JSON.stringify(pre, null, 2);
|
|
|
|
const postStr = JSON.stringify(post, null, 2);
|
|
|
|
const msg = `Test case is leaking resources.
|
|
|
|
Before: ${preStr}
|
2020-04-24 18:07:25 -04:00
|
|
|
After: ${postStr}
|
|
|
|
|
2020-06-12 11:58:04 -04:00
|
|
|
Make sure to close all open resource handles returned from Deno APIs before
|
2020-04-24 18:07:25 -04:00
|
|
|
finishing test case.`;
|
2020-03-18 19:25:55 -04:00
|
|
|
assert(preStr === postStr, msg);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-02-11 06:01:56 -05:00
|
|
|
export interface TestDefinition {
|
2020-04-01 04:47:23 -04:00
|
|
|
fn: () => void | Promise<void>;
|
2020-02-11 06:01:56 -05:00
|
|
|
name: string;
|
2020-03-19 05:58:12 -04:00
|
|
|
ignore?: boolean;
|
2020-06-12 11:58:04 -04:00
|
|
|
only?: boolean;
|
2020-04-23 08:40:16 -04:00
|
|
|
sanitizeOps?: boolean;
|
|
|
|
sanitizeResources?: boolean;
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
|
2020-03-05 05:52:18 -05:00
|
|
|
const TEST_REGISTRY: TestDefinition[] = [];
|
2020-02-11 06:01:56 -05:00
|
|
|
|
|
|
|
export function test(t: TestDefinition): void;
|
2020-04-01 04:47:23 -04:00
|
|
|
export function test(name: string, fn: () => void | Promise<void>): void;
|
2020-02-11 06:01:56 -05:00
|
|
|
// Main test function provided by Deno, as you can see it merely
|
|
|
|
// creates a new object with "name" and "fn" fields.
|
|
|
|
export function test(
|
2020-04-28 06:33:09 -04:00
|
|
|
t: string | TestDefinition,
|
2020-04-01 04:47:23 -04:00
|
|
|
fn?: () => void | Promise<void>
|
2020-02-11 06:01:56 -05:00
|
|
|
): void {
|
2020-03-15 05:34:24 -04:00
|
|
|
let testDef: TestDefinition;
|
2020-04-23 08:40:16 -04:00
|
|
|
const defaults = {
|
|
|
|
ignore: false,
|
2020-06-12 11:58:04 -04:00
|
|
|
only: false,
|
2020-04-23 08:40:16 -04:00
|
|
|
sanitizeOps: true,
|
|
|
|
sanitizeResources: true,
|
|
|
|
};
|
2020-02-11 06:01:56 -05:00
|
|
|
|
|
|
|
if (typeof t === "string") {
|
2020-03-15 05:34:24 -04:00
|
|
|
if (!fn || typeof fn != "function") {
|
|
|
|
throw new TypeError("Missing test function");
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
2020-03-15 05:34:24 -04:00
|
|
|
if (!t) {
|
|
|
|
throw new TypeError("The test name can't be empty");
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
2020-04-23 08:40:16 -04:00
|
|
|
testDef = { fn: fn as () => void | Promise<void>, name: t, ...defaults };
|
2020-02-11 06:01:56 -05:00
|
|
|
} else {
|
2020-03-15 05:34:24 -04:00
|
|
|
if (!t.fn) {
|
|
|
|
throw new TypeError("Missing test function");
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
2020-03-15 05:34:24 -04:00
|
|
|
if (!t.name) {
|
|
|
|
throw new TypeError("The test name can't be empty");
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
2020-04-23 08:40:16 -04:00
|
|
|
testDef = { ...defaults, ...t };
|
2020-03-18 19:25:55 -04:00
|
|
|
}
|
|
|
|
|
2020-04-23 08:40:16 -04:00
|
|
|
if (testDef.sanitizeOps) {
|
2020-03-18 19:25:55 -04:00
|
|
|
testDef.fn = assertOps(testDef.fn);
|
|
|
|
}
|
|
|
|
|
2020-04-23 08:40:16 -04:00
|
|
|
if (testDef.sanitizeResources) {
|
2020-03-18 19:25:55 -04:00
|
|
|
testDef.fn = assertResources(testDef.fn);
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
|
2020-03-15 05:34:24 -04:00
|
|
|
TEST_REGISTRY.push(testDef);
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
|
2020-04-27 08:51:22 -04:00
|
|
|
interface TestMessage {
|
2020-04-01 04:47:23 -04:00
|
|
|
start?: {
|
|
|
|
tests: TestDefinition[];
|
|
|
|
};
|
|
|
|
// Must be extensible, avoiding `testStart?: TestDefinition;`.
|
|
|
|
testStart?: {
|
|
|
|
[P in keyof TestDefinition]: TestDefinition[P];
|
|
|
|
};
|
|
|
|
testEnd?: {
|
|
|
|
name: string;
|
|
|
|
status: "passed" | "failed" | "ignored";
|
|
|
|
duration: number;
|
|
|
|
error?: Error;
|
|
|
|
};
|
|
|
|
end?: {
|
|
|
|
filtered: number;
|
|
|
|
ignored: number;
|
|
|
|
measured: number;
|
|
|
|
passed: number;
|
|
|
|
failed: number;
|
2020-06-12 11:58:04 -04:00
|
|
|
usedOnly: boolean;
|
2020-04-01 04:47:23 -04:00
|
|
|
duration: number;
|
|
|
|
results: Array<TestMessage["testEnd"] & {}>;
|
|
|
|
};
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
const encoder = new TextEncoder();
|
2020-03-15 05:34:24 -04:00
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
function log(msg: string, noNewLine = false): void {
|
|
|
|
if (!noNewLine) {
|
|
|
|
msg += "\n";
|
|
|
|
}
|
2020-03-13 10:57:32 -04:00
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
// Using `stdout` here because it doesn't force new lines
|
|
|
|
// compared to `console.log`; `core.print` on the other hand
|
|
|
|
// is line-buffered and doesn't output message without newline
|
|
|
|
stdout.writeSync(encoder.encode(msg));
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
function reportToConsole(message: TestMessage): void {
|
2020-06-19 06:10:31 -04:00
|
|
|
const redFailed = red("FAILED");
|
|
|
|
const greenOk = green("ok");
|
|
|
|
const yellowIgnored = yellow("ignored");
|
2020-04-01 04:47:23 -04:00
|
|
|
if (message.start != null) {
|
|
|
|
log(`running ${message.start.tests.length} tests`);
|
|
|
|
} else if (message.testStart != null) {
|
|
|
|
const { name } = message.testStart;
|
|
|
|
|
|
|
|
log(`test ${name} ... `, true);
|
|
|
|
return;
|
|
|
|
} else if (message.testEnd != null) {
|
|
|
|
switch (message.testEnd.status) {
|
|
|
|
case "passed":
|
2020-06-19 06:10:31 -04:00
|
|
|
log(`${greenOk} ${formatDuration(message.testEnd.duration)}`);
|
2020-04-01 04:47:23 -04:00
|
|
|
break;
|
|
|
|
case "failed":
|
2020-06-19 06:10:31 -04:00
|
|
|
log(`${redFailed} ${formatDuration(message.testEnd.duration)}`);
|
2020-04-01 04:47:23 -04:00
|
|
|
break;
|
|
|
|
case "ignored":
|
2020-06-19 06:10:31 -04:00
|
|
|
log(`${yellowIgnored} ${formatDuration(message.testEnd.duration)}`);
|
2020-04-01 04:47:23 -04:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
} else if (message.end != null) {
|
|
|
|
const failures = message.end.results.filter((m) => m.error != null);
|
|
|
|
if (failures.length > 0) {
|
|
|
|
log(`\nfailures:\n`);
|
|
|
|
|
|
|
|
for (const { name, error } of failures) {
|
|
|
|
log(name);
|
|
|
|
log(stringifyArgs([error!]));
|
|
|
|
log("");
|
|
|
|
}
|
2020-03-13 10:57:32 -04:00
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
log(`failures:\n`);
|
2020-03-15 12:58:59 -04:00
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
for (const { name } of failures) {
|
|
|
|
log(`\t${name}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
log(
|
2020-06-19 06:10:31 -04:00
|
|
|
`\ntest result: ${message.end.failed ? redFailed : greenOk}. ` +
|
2020-04-01 04:47:23 -04:00
|
|
|
`${message.end.passed} passed; ${message.end.failed} failed; ` +
|
|
|
|
`${message.end.ignored} ignored; ${message.end.measured} measured; ` +
|
|
|
|
`${message.end.filtered} filtered out ` +
|
|
|
|
`${formatDuration(message.end.duration)}\n`
|
|
|
|
);
|
2020-06-12 11:58:04 -04:00
|
|
|
|
|
|
|
if (message.end.usedOnly && message.end.failed == 0) {
|
2020-06-19 06:10:31 -04:00
|
|
|
log(`${redFailed} because the "only" option was used\n`);
|
2020-06-12 11:58:04 -04:00
|
|
|
}
|
2020-04-01 04:47:23 -04:00
|
|
|
}
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
exposeForTest("reportToConsole", reportToConsole);
|
2020-03-13 10:57:32 -04:00
|
|
|
|
|
|
|
// TODO: already implements AsyncGenerator<RunTestsMessage>, but add as "implements to class"
|
2020-04-01 04:47:23 -04:00
|
|
|
// TODO: implements PromiseLike<RunTestsEndResult>
|
2020-06-12 11:58:04 -04:00
|
|
|
class TestRunner {
|
2020-03-13 10:57:32 -04:00
|
|
|
readonly testsToRun: TestDefinition[];
|
2020-04-01 04:47:23 -04:00
|
|
|
readonly stats = {
|
2020-03-13 10:57:32 -04:00
|
|
|
filtered: 0,
|
|
|
|
ignored: 0,
|
|
|
|
measured: 0,
|
|
|
|
passed: 0,
|
2020-03-28 13:03:49 -04:00
|
|
|
failed: 0,
|
2020-03-13 10:57:32 -04:00
|
|
|
};
|
2020-06-12 11:58:04 -04:00
|
|
|
private usedOnly: boolean;
|
2020-03-13 10:57:32 -04:00
|
|
|
|
|
|
|
constructor(
|
2020-06-12 11:58:04 -04:00
|
|
|
tests: TestDefinition[],
|
2020-03-13 10:57:32 -04:00
|
|
|
public filterFn: (def: TestDefinition) => boolean,
|
|
|
|
public failFast: boolean
|
|
|
|
) {
|
2020-06-12 11:58:04 -04:00
|
|
|
const onlyTests = tests.filter(({ only }) => only);
|
|
|
|
this.usedOnly = onlyTests.length > 0;
|
|
|
|
const unfilteredTests = this.usedOnly ? onlyTests : tests;
|
|
|
|
this.testsToRun = unfilteredTests.filter(filterFn);
|
|
|
|
this.stats.filtered = unfilteredTests.length - this.testsToRun.length;
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
async *[Symbol.asyncIterator](): AsyncIterator<TestMessage> {
|
|
|
|
yield { start: { tests: this.testsToRun } };
|
2020-03-13 10:57:32 -04:00
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
const results: Array<TestMessage["testEnd"] & {}> = [];
|
2020-03-13 10:57:32 -04:00
|
|
|
const suiteStart = +new Date();
|
2020-04-01 04:47:23 -04:00
|
|
|
for (const test of this.testsToRun) {
|
|
|
|
const endMessage: Partial<TestMessage["testEnd"] & {}> = {
|
|
|
|
name: test.name,
|
|
|
|
duration: 0,
|
|
|
|
};
|
|
|
|
yield { testStart: { ...test } };
|
|
|
|
if (test.ignore) {
|
|
|
|
endMessage.status = "ignored";
|
2020-03-15 05:34:24 -04:00
|
|
|
this.stats.ignored++;
|
|
|
|
} else {
|
2020-03-13 10:57:32 -04:00
|
|
|
const start = +new Date();
|
2020-03-15 05:34:24 -04:00
|
|
|
try {
|
2020-04-01 04:47:23 -04:00
|
|
|
await test.fn();
|
|
|
|
endMessage.status = "passed";
|
2020-03-15 05:34:24 -04:00
|
|
|
this.stats.passed++;
|
|
|
|
} catch (err) {
|
2020-04-01 04:47:23 -04:00
|
|
|
endMessage.status = "failed";
|
|
|
|
endMessage.error = err;
|
2020-03-15 05:34:24 -04:00
|
|
|
this.stats.failed++;
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
2020-04-01 04:47:23 -04:00
|
|
|
endMessage.duration = +new Date() - start;
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
2020-04-01 04:47:23 -04:00
|
|
|
results.push(endMessage as TestMessage["testEnd"] & {});
|
|
|
|
yield { testEnd: endMessage as TestMessage["testEnd"] };
|
|
|
|
if (this.failFast && endMessage.error != null) {
|
2020-03-15 05:34:24 -04:00
|
|
|
break;
|
|
|
|
}
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
const duration = +new Date() - suiteStart;
|
|
|
|
|
2020-06-12 11:58:04 -04:00
|
|
|
yield {
|
|
|
|
end: { ...this.stats, usedOnly: this.usedOnly, duration, results },
|
|
|
|
};
|
2020-03-13 10:57:32 -04:00
|
|
|
}
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
|
2020-03-13 10:57:32 -04:00
|
|
|
function createFilterFn(
|
2020-04-02 09:26:40 -04:00
|
|
|
filter: undefined | string | RegExp,
|
2020-03-05 05:52:18 -05:00
|
|
|
skip: undefined | string | RegExp
|
2020-03-13 10:57:32 -04:00
|
|
|
): (def: TestDefinition) => boolean {
|
|
|
|
return (def: TestDefinition): boolean => {
|
2020-03-05 05:52:18 -05:00
|
|
|
let passes = true;
|
|
|
|
|
2020-04-02 09:26:40 -04:00
|
|
|
if (filter) {
|
|
|
|
if (filter instanceof RegExp) {
|
|
|
|
passes = passes && filter.test(def.name);
|
2020-03-05 05:52:18 -05:00
|
|
|
} else {
|
2020-04-02 09:26:40 -04:00
|
|
|
passes = passes && def.name.includes(filter);
|
2020-03-05 05:52:18 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (skip) {
|
|
|
|
if (skip instanceof RegExp) {
|
|
|
|
passes = passes && !skip.test(def.name);
|
|
|
|
} else {
|
|
|
|
passes = passes && !def.name.includes(skip);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return passes;
|
2020-03-13 10:57:32 -04:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-04-27 08:51:22 -04:00
|
|
|
interface RunTestsOptions {
|
2020-04-01 04:47:23 -04:00
|
|
|
exitOnFail?: boolean;
|
|
|
|
failFast?: boolean;
|
2020-04-02 09:26:40 -04:00
|
|
|
filter?: string | RegExp;
|
2020-04-01 04:47:23 -04:00
|
|
|
skip?: string | RegExp;
|
|
|
|
disableLog?: boolean;
|
|
|
|
reportToConsole?: boolean;
|
|
|
|
onMessage?: (message: TestMessage) => void | Promise<void>;
|
2020-03-05 05:52:18 -05:00
|
|
|
}
|
|
|
|
|
2020-04-27 08:51:22 -04:00
|
|
|
async function runTests({
|
2020-03-05 05:52:18 -05:00
|
|
|
exitOnFail = true,
|
|
|
|
failFast = false,
|
2020-04-02 09:26:40 -04:00
|
|
|
filter = undefined,
|
2020-03-05 05:52:18 -05:00
|
|
|
skip = undefined,
|
2020-03-13 10:57:32 -04:00
|
|
|
disableLog = false,
|
2020-04-01 04:47:23 -04:00
|
|
|
reportToConsole: reportToConsole_ = true,
|
|
|
|
onMessage = undefined,
|
|
|
|
}: RunTestsOptions = {}): Promise<TestMessage["end"] & {}> {
|
2020-04-02 09:26:40 -04:00
|
|
|
const filterFn = createFilterFn(filter, skip);
|
2020-06-12 11:58:04 -04:00
|
|
|
const testRunner = new TestRunner(TEST_REGISTRY, filterFn, failFast);
|
2020-02-11 06:01:56 -05:00
|
|
|
|
|
|
|
const originalConsole = globalThis.console;
|
|
|
|
|
|
|
|
if (disableLog) {
|
2020-06-02 00:24:44 -04:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
(globalThis as any).console = disabledConsole;
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
|
2020-04-01 04:47:23 -04:00
|
|
|
let endMsg: TestMessage["end"];
|
|
|
|
|
2020-06-12 11:58:04 -04:00
|
|
|
for await (const message of testRunner) {
|
2020-04-01 04:47:23 -04:00
|
|
|
if (onMessage != null) {
|
|
|
|
await onMessage(message);
|
|
|
|
}
|
|
|
|
if (reportToConsole_) {
|
|
|
|
reportToConsole(message);
|
|
|
|
}
|
|
|
|
if (message.end != null) {
|
|
|
|
endMsg = message.end;
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (disableLog) {
|
|
|
|
globalThis.console = originalConsole;
|
|
|
|
}
|
|
|
|
|
2020-06-12 11:58:04 -04:00
|
|
|
if ((endMsg!.failed > 0 || endMsg?.usedOnly) && exitOnFail) {
|
2020-03-13 10:57:32 -04:00
|
|
|
exit(1);
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
2020-03-13 10:57:32 -04:00
|
|
|
|
|
|
|
return endMsg!;
|
2020-02-11 06:01:56 -05:00
|
|
|
}
|
2020-04-27 08:51:22 -04:00
|
|
|
|
|
|
|
exposeForTest("runTests", runTests);
|