// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. "use strict"; ((window) => { const core = window.Deno.core; const colors = window.__bootstrap.colors; const { setExitHandler, exit } = window.__bootstrap.os; const { Console, inspectArgs } = window.__bootstrap.console; const { stdout } = window.__bootstrap.files; const { exposeForTest } = window.__bootstrap.internals; const { metrics } = window.__bootstrap.metrics; const { assert } = window.__bootstrap.util; const disabledConsole = new Console(() => {}); function delay(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function formatDuration(time = 0) { const gray = colors.maybeColor(colors.gray); const italic = colors.maybeColor(colors.italic); const timeStr = `(${time}ms)`; return gray(italic(timeStr)); } // Wrap test function in additional assertion that makes sure // 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. function assertOps(fn) { return async function asyncOpSanitizer() { const pre = metrics(); try { await fn(); } finally { // 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); } 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} After: - dispatched: ${post.opsDispatchedAsync} - completed: ${post.opsCompletedAsync} Make sure to await all promises returned from Deno APIs before finishing test case.`, ); }; } // Wrap test function in additional assertion that makes sure // the test case does not "leak" resources - ie. resource table after // the test has exactly the same contents as before the test. function assertResources( fn, ) { return async function resourceSanitizer() { const pre = core.resources(); await fn(); const post = core.resources(); const preStr = JSON.stringify(pre, null, 2); const postStr = JSON.stringify(post, null, 2); const msg = `Test case is leaking resources. Before: ${preStr} After: ${postStr} Make sure to close all open resource handles returned from Deno APIs before finishing test case.`; assert(preStr === postStr, msg); }; } // Wrap test function in additional assertion that makes sure // that the test case does not accidentally exit prematurely. function assertExit(fn) { return async function exitSanitizer() { setExitHandler((exitCode) => { assert( false, `Test case attempted to exit with exit code: ${exitCode}`, ); }); try { await fn(); } catch (err) { throw err; } finally { setExitHandler(null); } }; } const TEST_REGISTRY = []; // Main test function provided by Deno, as you can see it merely // creates a new object with "name" and "fn" fields. function test( t, fn, ) { let testDef; const defaults = { ignore: false, only: false, sanitizeOps: true, sanitizeResources: true, sanitizeExit: true, }; if (typeof t === "string") { if (!fn || typeof fn != "function") { throw new TypeError("Missing test function"); } if (!t) { throw new TypeError("The test name can't be empty"); } testDef = { fn: fn, name: t, ...defaults }; } else { if (!t.fn) { throw new TypeError("Missing test function"); } if (!t.name) { throw new TypeError("The test name can't be empty"); } testDef = { ...defaults, ...t }; } if (testDef.sanitizeOps) { testDef.fn = assertOps(testDef.fn); } if (testDef.sanitizeResources) { testDef.fn = assertResources(testDef.fn); } if (testDef.sanitizeExit) { testDef.fn = assertExit(testDef.fn); } TEST_REGISTRY.push(testDef); } const encoder = new TextEncoder(); function log(msg, noNewLine = false) { if (!noNewLine) { msg += "\n"; } // 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)); } function reportToConsole(message) { const green = colors.maybeColor(colors.green); const red = colors.maybeColor(colors.red); const yellow = colors.maybeColor(colors.yellow); const redFailed = red("FAILED"); const greenOk = green("ok"); const yellowIgnored = yellow("ignored"); 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": log(`${greenOk} ${formatDuration(message.testEnd.duration)}`); break; case "failed": log(`${redFailed} ${formatDuration(message.testEnd.duration)}`); break; case "ignored": log(`${yellowIgnored} ${formatDuration(message.testEnd.duration)}`); 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(inspectArgs([error])); log(""); } log(`failures:\n`); for (const { name } of failures) { log(`\t${name}`); } } log( `\ntest result: ${message.end.failed ? redFailed : greenOk}. ` + `${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`, ); if (message.end.usedOnly && message.end.failed == 0) { log(`${redFailed} because the "only" option was used\n`); } } } exposeForTest("reportToConsole", reportToConsole); // TODO(bartlomieju): already implements AsyncGenerator, but add as "implements to class" // TODO(bartlomieju): implements PromiseLike class TestRunner { #usedOnly = false; constructor( tests, filterFn, failFast, ) { this.stats = { filtered: 0, ignored: 0, measured: 0, passed: 0, failed: 0, }; this.filterFn = filterFn; this.failFast = failFast; 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; } async *[Symbol.asyncIterator]() { yield { start: { tests: this.testsToRun } }; const results = []; const suiteStart = +new Date(); for (const test of this.testsToRun) { const endMessage = { name: test.name, duration: 0, }; yield { testStart: { ...test } }; if (test.ignore) { endMessage.status = "ignored"; this.stats.ignored++; } else { const start = +new Date(); try { await test.fn(); endMessage.status = "passed"; this.stats.passed++; } catch (err) { endMessage.status = "failed"; endMessage.error = err; this.stats.failed++; } endMessage.duration = +new Date() - start; } results.push(endMessage); yield { testEnd: endMessage }; if (this.failFast && endMessage.error != null) { break; } } const duration = +new Date() - suiteStart; yield { end: { ...this.stats, usedOnly: this.#usedOnly, duration, results }, }; } } function createFilterFn( filter, skip, ) { return (def) => { let passes = true; if (filter) { if (filter instanceof RegExp) { passes = passes && filter.test(def.name); } else if (filter.startsWith("/") && filter.endsWith("/")) { const filterAsRegex = new RegExp(filter.slice(1, filter.length - 1)); passes = passes && filterAsRegex.test(def.name); } else { passes = passes && def.name.includes(filter); } } if (skip) { if (skip instanceof RegExp) { passes = passes && !skip.test(def.name); } else { passes = passes && !def.name.includes(skip); } } return passes; }; } exposeForTest("createFilterFn", createFilterFn); async function runTests({ exitOnFail = true, failFast = false, filter = undefined, skip = undefined, disableLog = false, reportToConsole: reportToConsole_ = true, onMessage = undefined, } = {}) { const filterFn = createFilterFn(filter, skip); const testRunner = new TestRunner(TEST_REGISTRY, filterFn, failFast); const originalConsole = globalThis.console; if (disableLog) { globalThis.console = disabledConsole; } let endMsg; for await (const message of testRunner) { if (onMessage != null) { await onMessage(message); } if (reportToConsole_) { reportToConsole(message); } if (message.end != null) { endMsg = message.end; } } if (disableLog) { globalThis.console = originalConsole; } if ((endMsg.failed > 0 || endMsg?.usedOnly) && exitOnFail) { exit(1); } return endMsg; } exposeForTest("runTests", runTests); window.__bootstrap.testing = { test, }; })(this);