diff options
Diffstat (limited to 'std/testing')
27 files changed, 3823 insertions, 0 deletions
diff --git a/std/testing/README.md b/std/testing/README.md new file mode 100644 index 000000000..88923c1c9 --- /dev/null +++ b/std/testing/README.md @@ -0,0 +1,223 @@ +# Testing + +This module provides a few basic utilities to make testing easier and +consistent in Deno. + +## Usage + +The module exports a `test` function which is the test harness in Deno. It +accepts either a function (including async functions) or an object which +contains a `name` property and a `fn` property. When running tests and +outputting the results, the name of the past function is used, or if the +object is passed, the `name` property is used to identify the test. If the assertion is false an `AssertionError` will be thrown. + +Asserts are exposed in `testing/asserts.ts` module. + +- `equal()` - Deep comparison function, where `actual` and `expected` are + compared deeply, and if they vary, `equal` returns `false`. +- `assert()` - Expects a boolean value, throws if the value is `false`. +- `assertEquals()` - Uses the `equal` comparison and throws if the `actual` and + `expected` are not equal. +- `assertNotEquals()` - Uses the `equal` comparison and throws if the `actual` and + `expected` are equal. +- `assertStrictEq()` - Compares `actual` and `expected` strictly, therefore + for non-primitives the values must reference the same instance. +- `assertStrContains()` - Make an assertion that `actual` contains `expected`. +- `assertMatch()` - Make an assertion that `actual` match RegExp `expected`. +- `assertArrayContains()` - Make an assertion that `actual` array contains the `expected` values. +- `assertThrows()` - Expects the passed `fn` to throw. If `fn` does not throw, + this function does. Also compares any errors thrown to an optional expected + `Error` class and checks that the error `.message` includes an optional + string. +- `assertThrowsAsync()` - Expects the passed `fn` to be async and throw (or + return a `Promise` that rejects). If the `fn` does not throw or reject, this + function will throw asynchronously. Also compares any errors thrown to an + optional expected `Error` class and checks that the error `.message` includes + an optional string. +- `unimplemented()` - Use this to stub out methods that will throw when invoked +- `unreachable()` - Used to assert unreachable code + +`runTests()` executes the declared tests. It accepts a `RunOptions` parameter: + +- parallel : Execute tests in a parallel way. +- exitOnFail : if one test fails, test will throw an error and stop the tests. If not all tests will be processed. + +Basic usage: + +```ts +import { runTests, test } from "https://deno.land/std/testing/mod.ts"; +import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; + +test({ + name: "testing example", + fn(): void { + assertEquals("world", "world"); + assertEquals({ hello: "world" }, { hello: "world" }); + } +}); + +runTests(); +``` + +Short syntax (named function instead of object): + +```ts +test(function example(): void { + assertEquals("world", "world"); + assertEquals({ hello: "world" }, { hello: "world" }); +}); +``` + +Using `assertStrictEq()`: + +```ts +test(function isStrictlyEqual(): void { + const a = {}; + const b = a; + assertStrictEq(a, b); +}); + +// This test fails +test(function isNotStrictlyEqual(): void { + const a = {}; + const b = {}; + assertStrictEq(a, b); +}); +``` + +Using `assertThrows()`: + +```ts +test(function doesThrow(): void { + assertThrows((): void => { + throw new TypeError("hello world!"); + }); + assertThrows((): void => { + throw new TypeError("hello world!"); + }, TypeError); + assertThrows( + (): void => { + throw new TypeError("hello world!"); + }, + TypeError, + "hello" + ); +}); + +// This test will not pass +test(function fails(): void { + assertThrows((): void => { + console.log("Hello world"); + }); +}); +``` + +Using `assertThrowsAsync()`: + +```ts +test(async function doesThrow(): Promise<void> { + await assertThrowsAsync( + async (): Promise<void> => { + throw new TypeError("hello world!"); + } + ); + await assertThrowsAsync(async (): Promise<void> => { + throw new TypeError("hello world!"); + }, TypeError); + await assertThrowsAsync( + async (): Promise<void> => { + throw new TypeError("hello world!"); + }, + TypeError, + "hello" + ); + await assertThrowsAsync( + async (): Promise<void> => { + return Promise.reject(new Error()); + } + ); +}); + +// This test will not pass +test(async function fails(): Promise<void> { + await assertThrowsAsync( + async (): Promise<void> => { + console.log("Hello world"); + } + ); +}); +``` + +### Benching Usage + +Basic usage: + +```ts +import { runBenchmarks, bench } from "https://deno.land/std/testing/bench.ts"; + +bench(function forIncrementX1e9(b): void { + b.start(); + for (let i = 0; i < 1e9; i++); + b.stop(); +}); + +runBenchmarks(); +``` + +Averaging execution time over multiple runs: + +```ts +bench({ + name: "runs100ForIncrementX1e6", + runs: 100, + func(b): void { + b.start(); + for (let i = 0; i < 1e6; i++); + b.stop(); + } +}); +``` + +#### Benching API + +##### `bench(benchmark: BenchmarkDefinition | BenchmarkFunction): void` + +Registers a benchmark that will be run once `runBenchmarks` is called. + +##### `runBenchmarks(opts?: BenchmarkRunOptions): Promise<void>` + +Runs all registered benchmarks serially. Filtering can be applied by setting +`BenchmarkRunOptions.only` and/or `BenchmarkRunOptions.skip` to regular expressions matching benchmark names. + +##### `runIfMain(meta: ImportMeta, opts?: BenchmarkRunOptions): Promise<void>` + +Runs specified benchmarks if the enclosing script is main. + +##### Other exports + +```ts +/** Provides methods for starting and stopping a benchmark clock. */ +export interface BenchmarkTimer { + start: () => void; + stop: () => void; +} + +/** Defines a benchmark through a named function. */ +export interface BenchmarkFunction { + (b: BenchmarkTimer): void | Promise<void>; + name: string; +} + +/** Defines a benchmark definition with configurable runs. */ +export interface BenchmarkDefinition { + func: BenchmarkFunction; + name: string; + runs?: number; +} + +/** Defines runBenchmark's run constraints by matching benchmark names. */ +export interface BenchmarkRunOptions { + only?: RegExp; + skip?: RegExp; +} +``` diff --git a/std/testing/asserts.ts b/std/testing/asserts.ts new file mode 100644 index 000000000..230e2c8d2 --- /dev/null +++ b/std/testing/asserts.ts @@ -0,0 +1,359 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { red, green, white, gray, bold } from "../fmt/colors.ts"; +import diff, { DiffType, DiffResult } from "./diff.ts"; +import { format } from "./format.ts"; + +const CAN_NOT_DISPLAY = "[Cannot display]"; + +interface Constructor { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): any; +} + +export class AssertionError extends Error { + constructor(message: string) { + super(message); + this.name = "AssertionError"; + } +} + +function createStr(v: unknown): string { + try { + return format(v); + } catch (e) { + return red(CAN_NOT_DISPLAY); + } +} + +function createColor(diffType: DiffType): (s: string) => string { + switch (diffType) { + case DiffType.added: + return (s: string): string => green(bold(s)); + case DiffType.removed: + return (s: string): string => red(bold(s)); + default: + return white; + } +} + +function createSign(diffType: DiffType): string { + switch (diffType) { + case DiffType.added: + return "+ "; + case DiffType.removed: + return "- "; + default: + return " "; + } +} + +function buildMessage(diffResult: ReadonlyArray<DiffResult<string>>): string[] { + const messages: string[] = []; + messages.push(""); + messages.push(""); + messages.push( + ` ${gray(bold("[Diff]"))} ${red(bold("Left"))} / ${green(bold("Right"))}` + ); + messages.push(""); + messages.push(""); + diffResult.forEach((result: DiffResult<string>): void => { + const c = createColor(result.type); + messages.push(c(`${createSign(result.type)}${result.value}`)); + }); + messages.push(""); + + return messages; +} + +export function equal(c: unknown, d: unknown): boolean { + const seen = new Map(); + return (function compare(a: unknown, b: unknown): boolean { + if (a && a instanceof Set && b && b instanceof Set) { + if (a.size !== b.size) { + return false; + } + for (const item of b) { + if (!a.has(item)) { + return false; + } + } + return true; + } + // Have to render RegExp & Date for string comparison + // unless it's mistreated as object + if ( + a && + b && + ((a instanceof RegExp && b instanceof RegExp) || + (a instanceof Date && b instanceof Date)) + ) { + return String(a) === String(b); + } + if (Object.is(a, b)) { + return true; + } + if (a && typeof a === "object" && b && typeof b === "object") { + if (seen.get(a) === b) { + return true; + } + if (Object.keys(a || {}).length !== Object.keys(b || {}).length) { + return false; + } + const merged = { ...a, ...b }; + for (const key in merged) { + type Key = keyof typeof merged; + if (!compare(a && a[key as Key], b && b[key as Key])) { + return false; + } + } + seen.set(a, b); + return true; + } + return false; + })(c, d); +} + +/** Make an assertion, if not `true`, then throw. */ +export function assert(expr: boolean, msg = ""): void { + if (!expr) { + throw new AssertionError(msg); + } +} + +/** + * Make an assertion that `actual` and `expected` are equal, deeply. If not + * deeply equal, then throw. + */ +export function assertEquals( + actual: unknown, + expected: unknown, + msg?: string +): void { + if (equal(actual, expected)) { + return; + } + let message = ""; + const actualString = createStr(actual); + const expectedString = createStr(expected); + try { + const diffResult = diff( + actualString.split("\n"), + expectedString.split("\n") + ); + message = buildMessage(diffResult).join("\n"); + } catch (e) { + message = `\n${red(CAN_NOT_DISPLAY)} + \n\n`; + } + if (msg) { + message = msg; + } + throw new AssertionError(message); +} + +/** + * Make an assertion that `actual` and `expected` are not equal, deeply. + * If not then throw. + */ +export function assertNotEquals( + actual: unknown, + expected: unknown, + msg?: string +): void { + if (!equal(actual, expected)) { + return; + } + let actualString: string; + let expectedString: string; + try { + actualString = String(actual); + } catch (e) { + actualString = "[Cannot display]"; + } + try { + expectedString = String(expected); + } catch (e) { + expectedString = "[Cannot display]"; + } + if (!msg) { + msg = `actual: ${actualString} expected: ${expectedString}`; + } + throw new AssertionError(msg); +} + +/** + * Make an assertion that `actual` and `expected` are strictly equal. If + * not then throw. + */ +export function assertStrictEq( + actual: unknown, + expected: unknown, + msg?: string +): void { + if (actual !== expected) { + let actualString: string; + let expectedString: string; + try { + actualString = String(actual); + } catch (e) { + actualString = "[Cannot display]"; + } + try { + expectedString = String(expected); + } catch (e) { + expectedString = "[Cannot display]"; + } + if (!msg) { + msg = `actual: ${actualString} expected: ${expectedString}`; + } + throw new AssertionError(msg); + } +} + +/** + * Make an assertion that actual contains expected. If not + * then thrown. + */ +export function assertStrContains( + actual: string, + expected: string, + msg?: string +): void { + if (!actual.includes(expected)) { + if (!msg) { + msg = `actual: "${actual}" expected to contains: "${expected}"`; + } + throw new AssertionError(msg); + } +} + +/** + * Make an assertion that `actual` contains the `expected` values + * If not then thrown. + */ +export function assertArrayContains( + actual: unknown[], + expected: unknown[], + msg?: string +): void { + const missing: unknown[] = []; + for (let i = 0; i < expected.length; i++) { + let found = false; + for (let j = 0; j < actual.length; j++) { + if (equal(expected[i], actual[j])) { + found = true; + break; + } + } + if (!found) { + missing.push(expected[i]); + } + } + if (missing.length === 0) { + return; + } + if (!msg) { + msg = `actual: "${actual}" expected to contains: "${expected}"`; + msg += "\n"; + msg += `missing: ${missing}`; + } + throw new AssertionError(msg); +} + +/** + * Make an assertion that `actual` match RegExp `expected`. If not + * then thrown + */ +export function assertMatch( + actual: string, + expected: RegExp, + msg?: string +): void { + if (!expected.test(actual)) { + if (!msg) { + msg = `actual: "${actual}" expected to match: "${expected}"`; + } + throw new AssertionError(msg); + } +} + +/** + * Forcefully throws a failed assertion + */ +export function fail(msg?: string): void { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + assert(false, `Failed assertion${msg ? `: ${msg}` : "."}`); +} + +/** Executes a function, expecting it to throw. If it does not, then it + * throws. An error class and a string that should be included in the + * error message can also be asserted. + */ +export function assertThrows( + fn: () => void, + ErrorClass?: Constructor, + msgIncludes = "", + msg?: string +): void { + let doesThrow = false; + try { + fn(); + } catch (e) { + if (ErrorClass && !(Object.getPrototypeOf(e) === ErrorClass.prototype)) { + msg = `Expected error to be instance of "${ErrorClass.name}"${ + msg ? `: ${msg}` : "." + }`; + throw new AssertionError(msg); + } + if (msgIncludes && !e.message.includes(msgIncludes)) { + msg = `Expected error message to include "${msgIncludes}", but got "${ + e.message + }"${msg ? `: ${msg}` : "."}`; + throw new AssertionError(msg); + } + doesThrow = true; + } + if (!doesThrow) { + msg = `Expected function to throw${msg ? `: ${msg}` : "."}`; + throw new AssertionError(msg); + } +} + +export async function assertThrowsAsync( + fn: () => Promise<void>, + ErrorClass?: Constructor, + msgIncludes = "", + msg?: string +): Promise<void> { + let doesThrow = false; + try { + await fn(); + } catch (e) { + if (ErrorClass && !(Object.getPrototypeOf(e) === ErrorClass.prototype)) { + msg = `Expected error to be instance of "${ErrorClass.name}"${ + msg ? `: ${msg}` : "." + }`; + throw new AssertionError(msg); + } + if (msgIncludes && !e.message.includes(msgIncludes)) { + msg = `Expected error message to include "${msgIncludes}", but got "${ + e.message + }"${msg ? `: ${msg}` : "."}`; + throw new AssertionError(msg); + } + doesThrow = true; + } + if (!doesThrow) { + msg = `Expected function to throw${msg ? `: ${msg}` : "."}`; + throw new AssertionError(msg); + } +} + +/** Use this to stub out methods that will throw when invoked. */ +export function unimplemented(msg?: string): never { + throw new AssertionError(msg || "unimplemented"); +} + +/** Use this to assert unreachable code. */ +export function unreachable(): never { + throw new AssertionError("unreachable"); +} diff --git a/std/testing/asserts_test.ts b/std/testing/asserts_test.ts new file mode 100644 index 000000000..b480fe7c9 --- /dev/null +++ b/std/testing/asserts_test.ts @@ -0,0 +1,253 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. + +import { + assert, + assertNotEquals, + assertStrContains, + assertArrayContains, + assertMatch, + assertEquals, + assertThrows, + AssertionError, + equal, + fail, + unimplemented, + unreachable +} from "./asserts.ts"; +import { test } from "./mod.ts"; +import { red, green, white, gray, bold } from "../fmt/colors.ts"; + +test(function testingEqual(): void { + assert(equal("world", "world")); + assert(!equal("hello", "world")); + assert(equal(5, 5)); + assert(!equal(5, 6)); + assert(equal(NaN, NaN)); + assert(equal({ hello: "world" }, { hello: "world" })); + assert(!equal({ world: "hello" }, { hello: "world" })); + assert( + equal( + { hello: "world", hi: { there: "everyone" } }, + { hello: "world", hi: { there: "everyone" } } + ) + ); + assert( + !equal( + { hello: "world", hi: { there: "everyone" } }, + { hello: "world", hi: { there: "everyone else" } } + ) + ); + assert(equal(/deno/, /deno/)); + assert(!equal(/deno/, /node/)); + assert(equal(new Date(2019, 0, 3), new Date(2019, 0, 3))); + assert(!equal(new Date(2019, 0, 3), new Date(2019, 1, 3))); + assert(equal(new Set([1]), new Set([1]))); + assert(!equal(new Set([1]), new Set([2]))); + assert(equal(new Set([1, 2, 3]), new Set([3, 2, 1]))); + assert(!equal(new Set([1, 2]), new Set([3, 2, 1]))); + assert(!equal(new Set([1, 2, 3]), new Set([4, 5, 6]))); + assert(equal(new Set("denosaurus"), new Set("denosaurussss"))); +}); + +test(function testingNotEquals(): void { + const a = { foo: "bar" }; + const b = { bar: "foo" }; + assertNotEquals(a, b); + assertNotEquals("Denosaurus", "Tyrannosaurus"); + let didThrow; + try { + assertNotEquals("Raptor", "Raptor"); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); +}); + +test(function testingAssertStringContains(): void { + assertStrContains("Denosaurus", "saur"); + assertStrContains("Denosaurus", "Deno"); + assertStrContains("Denosaurus", "rus"); + let didThrow; + try { + assertStrContains("Denosaurus", "Raptor"); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); +}); + +test(function testingArrayContains(): void { + const fixture = ["deno", "iz", "luv"]; + const fixtureObject = [{ deno: "luv" }, { deno: "Js" }]; + assertArrayContains(fixture, ["deno"]); + assertArrayContains(fixtureObject, [{ deno: "luv" }]); + let didThrow; + try { + assertArrayContains(fixtureObject, [{ deno: "node" }]); + didThrow = false; + } catch (e) { + assert(e instanceof AssertionError); + didThrow = true; + } + assertEquals(didThrow, true); +}); + +test(function testingAssertStringContainsThrow(): void { + let didThrow = false; + try { + assertStrContains("Denosaurus from Jurassic", "Raptor"); + } catch (e) { + assert( + e.message === + `actual: "Denosaurus from Jurassic" expected to contains: "Raptor"` + ); + assert(e instanceof AssertionError); + didThrow = true; + } + assert(didThrow); +}); + +test(function testingAssertStringMatching(): void { + assertMatch("foobar@deno.com", RegExp(/[a-zA-Z]+@[a-zA-Z]+.com/)); +}); + +test(function testingAssertStringMatchingThrows(): void { + let didThrow = false; + try { + assertMatch("Denosaurus from Jurassic", RegExp(/Raptor/)); + } catch (e) { + assert( + e.message === + `actual: "Denosaurus from Jurassic" expected to match: "/Raptor/"` + ); + assert(e instanceof AssertionError); + didThrow = true; + } + assert(didThrow); +}); + +test(function testingAssertsUnimplemented(): void { + let didThrow = false; + try { + unimplemented(); + } catch (e) { + assert(e.message === "unimplemented"); + assert(e instanceof AssertionError); + didThrow = true; + } + assert(didThrow); +}); + +test(function testingAssertsUnreachable(): void { + let didThrow = false; + try { + unreachable(); + } catch (e) { + assert(e.message === "unreachable"); + assert(e instanceof AssertionError); + didThrow = true; + } + assert(didThrow); +}); + +test(function testingAssertFail(): void { + assertThrows(fail, AssertionError, "Failed assertion."); + assertThrows( + (): void => { + fail("foo"); + }, + AssertionError, + "Failed assertion: foo" + ); +}); + +const createHeader = (): string[] => [ + "", + "", + ` ${gray(bold("[Diff]"))} ${red(bold("Left"))} / ${green(bold("Right"))}`, + "", + "" +]; + +const added: (s: string) => string = (s: string): string => green(bold(s)); +const removed: (s: string) => string = (s: string): string => red(bold(s)); + +test({ + name: "pass case", + fn(): void { + assertEquals({ a: 10 }, { a: 10 }); + assertEquals(true, true); + assertEquals(10, 10); + assertEquals("abc", "abc"); + assertEquals({ a: 10, b: { c: "1" } }, { a: 10, b: { c: "1" } }); + } +}); + +test({ + name: "failed with number", + fn(): void { + assertThrows( + (): void => assertEquals(1, 2), + AssertionError, + [...createHeader(), removed(`- 1`), added(`+ 2`), ""].join("\n") + ); + } +}); + +test({ + name: "failed with number vs string", + fn(): void { + assertThrows( + (): void => assertEquals(1, "1"), + AssertionError, + [...createHeader(), removed(`- 1`), added(`+ "1"`)].join("\n") + ); + } +}); + +test({ + name: "failed with array", + fn(): void { + assertThrows( + (): void => assertEquals([1, "2", 3], ["1", "2", 3]), + AssertionError, + [ + ...createHeader(), + white(" Array ["), + removed(`- 1,`), + added(`+ "1",`), + white(' "2",'), + white(" 3,"), + white(" ]"), + "" + ].join("\n") + ); + } +}); + +test({ + name: "failed with object", + fn(): void { + assertThrows( + (): void => assertEquals({ a: 1, b: "2", c: 3 }, { a: 1, b: 2, c: [3] }), + AssertionError, + [ + ...createHeader(), + white(" Object {"), + white(` "a": 1,`), + added(`+ "b": 2,`), + added(`+ "c": Array [`), + added(`+ 3,`), + added(`+ ],`), + removed(`- "b": "2",`), + removed(`- "c": 3,`), + white(" }"), + "" + ].join("\n") + ); + } +}); diff --git a/std/testing/bench.ts b/std/testing/bench.ts new file mode 100644 index 000000000..3bb62526d --- /dev/null +++ b/std/testing/bench.ts @@ -0,0 +1,179 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. + +const { exit, noColor } = Deno; + +interface BenchmarkClock { + start: number; + stop: number; +} + +/** Provides methods for starting and stopping a benchmark clock. */ +export interface BenchmarkTimer { + start: () => void; + stop: () => void; +} + +/** Defines a benchmark through a named function. */ +export interface BenchmarkFunction { + (b: BenchmarkTimer): void | Promise<void>; + name: string; +} + +/** Defines a benchmark definition with configurable runs. */ +export interface BenchmarkDefinition { + func: BenchmarkFunction; + name: string; + runs?: number; +} + +/** Defines runBenchmark's run constraints by matching benchmark names. */ +export interface BenchmarkRunOptions { + only?: RegExp; + skip?: RegExp; +} + +function red(text: string): string { + return noColor ? text : `\x1b[31m${text}\x1b[0m`; +} + +function blue(text: string): string { + return noColor ? text : `\x1b[34m${text}\x1b[0m`; +} + +function verifyOr1Run(runs?: number): number { + return runs && runs >= 1 && runs !== Infinity ? Math.floor(runs) : 1; +} + +function assertTiming(clock: BenchmarkClock): void { + // NaN indicates that a benchmark has not been timed properly + if (!clock.stop) { + throw new Error("The benchmark timer's stop method must be called"); + } else if (!clock.start) { + throw new Error("The benchmark timer's start method must be called"); + } else if (clock.start > clock.stop) { + throw new Error( + "The benchmark timer's start method must be called before its " + + "stop method" + ); + } +} + +function createBenchmarkTimer(clock: BenchmarkClock): BenchmarkTimer { + return { + start(): void { + clock.start = performance.now(); + }, + stop(): void { + clock.stop = performance.now(); + } + }; +} + +const candidates: BenchmarkDefinition[] = []; + +/** Registers a benchmark as a candidate for the runBenchmarks executor. */ +export function bench( + benchmark: BenchmarkDefinition | BenchmarkFunction +): void { + if (!benchmark.name) { + throw new Error("The benchmark function must not be anonymous"); + } + if (typeof benchmark === "function") { + candidates.push({ name: benchmark.name, runs: 1, func: benchmark }); + } else { + candidates.push({ + name: benchmark.name, + runs: verifyOr1Run(benchmark.runs), + func: benchmark.func + }); + } +} + +/** Runs all registered and non-skipped benchmarks serially. */ +export async function runBenchmarks({ + only = /[^\s]/, + skip = /^\s*$/ +}: BenchmarkRunOptions = {}): Promise<void> { + // Filtering candidates by the "only" and "skip" constraint + const benchmarks: BenchmarkDefinition[] = candidates.filter( + ({ name }): boolean => only.test(name) && !skip.test(name) + ); + // Init main counters and error flag + const filtered = candidates.length - benchmarks.length; + let measured = 0; + let failed = false; + // Setting up a shared benchmark clock and timer + const clock: BenchmarkClock = { start: NaN, stop: NaN }; + const b = createBenchmarkTimer(clock); + // Iterating given benchmark definitions (await-in-loop) + console.log( + "running", + benchmarks.length, + `benchmark${benchmarks.length === 1 ? " ..." : "s ..."}` + ); + for (const { name, runs = 0, func } of benchmarks) { + // See https://github.com/denoland/deno/pull/1452 about groupCollapsed + console.groupCollapsed(`benchmark ${name} ... `); + // Trying benchmark.func + let result = ""; + try { + if (runs === 1) { + // b is a benchmark timer interfacing an unset (NaN) benchmark clock + await func(b); + // Making sure the benchmark was started/stopped properly + assertTiming(clock); + result = `${clock.stop - clock.start}ms`; + } else if (runs > 1) { + // Averaging runs + let pendingRuns = runs; + let totalMs = 0; + // Would be better 2 not run these serially + while (true) { + // b is a benchmark timer interfacing an unset (NaN) benchmark clock + await func(b); + // Making sure the benchmark was started/stopped properly + assertTiming(clock); + // Summing up + totalMs += clock.stop - clock.start; + // Resetting the benchmark clock + clock.start = clock.stop = NaN; + // Once all ran + if (!--pendingRuns) { + result = `${runs} runs avg: ${totalMs / runs}ms`; + break; + } + } + } + } catch (err) { + failed = true; + console.groupEnd(); + console.error(red(err.stack)); + break; + } + // Reporting + console.log(blue(result)); + console.groupEnd(); + measured++; + // Resetting the benchmark clock + clock.start = clock.stop = NaN; + } + // Closing results + console.log( + `benchmark result: ${failed ? red("FAIL") : blue("DONE")}. ` + + `${measured} measured; ${filtered} filtered` + ); + // Making sure the program exit code is not zero in case of failure + if (failed) { + setTimeout((): void => exit(1), 0); + } +} + +/** Runs specified benchmarks if the enclosing script is main. */ +export async function runIfMain( + meta: ImportMeta, + opts?: BenchmarkRunOptions +): Promise<void> { + if (meta.main) { + return runBenchmarks(opts); + } +} diff --git a/std/testing/bench_example.ts b/std/testing/bench_example.ts new file mode 100644 index 000000000..d27fb97e8 --- /dev/null +++ b/std/testing/bench_example.ts @@ -0,0 +1,29 @@ +// https://deno.land/std/testing/bench.ts +import { BenchmarkTimer, bench, runIfMain } from "./bench.ts"; + +// Basic +bench(function forIncrementX1e9(b: BenchmarkTimer): void { + b.start(); + for (let i = 0; i < 1e9; i++); + b.stop(); +}); + +// Reporting average measured time for $runs runs of func +bench({ + name: "runs100ForIncrementX1e6", + runs: 100, + func(b): void { + b.start(); + for (let i = 0; i < 1e6; i++); + b.stop(); + } +}); + +// Itsabug +bench(function throwing(b): void { + b.start(); + // Throws bc the timer's stop method is never called +}); + +// Bench control +runIfMain(import.meta, { skip: /throw/ }); diff --git a/std/testing/bench_test.ts b/std/testing/bench_test.ts new file mode 100644 index 000000000..8af2f0d6d --- /dev/null +++ b/std/testing/bench_test.ts @@ -0,0 +1,60 @@ +import { test, runIfMain } from "./mod.ts"; +import { bench, runBenchmarks } from "./bench.ts"; + +import "./bench_example.ts"; + +test(async function benching(): Promise<void> { + bench(function forIncrementX1e9(b): void { + b.start(); + for (let i = 0; i < 1e9; i++); + b.stop(); + }); + + bench(function forDecrementX1e9(b): void { + b.start(); + for (let i = 1e9; i > 0; i--); + b.stop(); + }); + + bench(async function forAwaitFetchDenolandX10(b): Promise<void> { + b.start(); + for (let i = 0; i < 10; i++) { + const r = await fetch("https://deno.land/"); + await r.text(); + } + b.stop(); + }); + + bench(async function promiseAllFetchDenolandX10(b): Promise<void> { + const urls = new Array(10).fill("https://deno.land/"); + b.start(); + await Promise.all( + urls.map( + async (denoland: string): Promise<void> => { + const r = await fetch(denoland); + await r.text(); + } + ) + ); + b.stop(); + }); + + bench({ + name: "runs100ForIncrementX1e6", + runs: 100, + func(b): void { + b.start(); + for (let i = 0; i < 1e6; i++); + b.stop(); + } + }); + + bench(function throwing(b): void { + b.start(); + // Throws bc the timer's stop method is never called + }); + + await runBenchmarks({ skip: /throw/ }); +}); + +runIfMain(import.meta); diff --git a/std/testing/diff.ts b/std/testing/diff.ts new file mode 100644 index 000000000..dd544ac24 --- /dev/null +++ b/std/testing/diff.ts @@ -0,0 +1,220 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +interface FarthestPoint { + y: number; + id: number; +} + +export enum DiffType { + removed = "removed", + common = "common", + added = "added" +} + +export interface DiffResult<T> { + type: DiffType; + value: T; +} + +const REMOVED = 1; +const COMMON = 2; +const ADDED = 3; + +function createCommon<T>(A: T[], B: T[], reverse?: boolean): T[] { + const common = []; + if (A.length === 0 || B.length === 0) return []; + for (let i = 0; i < Math.min(A.length, B.length); i += 1) { + if ( + A[reverse ? A.length - i - 1 : i] === B[reverse ? B.length - i - 1 : i] + ) { + common.push(A[reverse ? A.length - i - 1 : i]); + } else { + return common; + } + } + return common; +} + +export default function diff<T>(A: T[], B: T[]): Array<DiffResult<T>> { + const prefixCommon = createCommon(A, B); + const suffixCommon = createCommon( + A.slice(prefixCommon.length), + B.slice(prefixCommon.length), + true + ).reverse(); + A = suffixCommon.length + ? A.slice(prefixCommon.length, -suffixCommon.length) + : A.slice(prefixCommon.length); + B = suffixCommon.length + ? B.slice(prefixCommon.length, -suffixCommon.length) + : B.slice(prefixCommon.length); + const swapped = B.length > A.length; + [A, B] = swapped ? [B, A] : [A, B]; + const M = A.length; + const N = B.length; + if (!M && !N && !suffixCommon.length && !prefixCommon.length) return []; + if (!N) { + return [ + ...prefixCommon.map( + (c): DiffResult<typeof c> => ({ type: DiffType.common, value: c }) + ), + ...A.map( + (a): DiffResult<typeof a> => ({ + type: swapped ? DiffType.added : DiffType.removed, + value: a + }) + ), + ...suffixCommon.map( + (c): DiffResult<typeof c> => ({ type: DiffType.common, value: c }) + ) + ]; + } + const offset = N; + const delta = M - N; + const size = M + N + 1; + const fp = new Array(size).fill({ y: -1 }); + /** + * INFO: + * This buffer is used to save memory and improve performance. + * The first half is used to save route and last half is used to save diff + * type. + * This is because, when I kept new uint8array area to save type,performance + * worsened. + */ + const routes = new Uint32Array((M * N + size + 1) * 2); + const diffTypesPtrOffset = routes.length / 2; + let ptr = 0; + let p = -1; + + function backTrace<T>( + A: T[], + B: T[], + current: FarthestPoint, + swapped: boolean + ): Array<{ + type: DiffType; + value: T; + }> { + const M = A.length; + const N = B.length; + const result = []; + let a = M - 1; + let b = N - 1; + let j = routes[current.id]; + let type = routes[current.id + diffTypesPtrOffset]; + while (true) { + if (!j && !type) break; + const prev = j; + if (type === REMOVED) { + result.unshift({ + type: swapped ? DiffType.removed : DiffType.added, + value: B[b] + }); + b -= 1; + } else if (type === ADDED) { + result.unshift({ + type: swapped ? DiffType.added : DiffType.removed, + value: A[a] + }); + a -= 1; + } else { + result.unshift({ type: DiffType.common, value: A[a] }); + a -= 1; + b -= 1; + } + j = routes[prev]; + type = routes[prev + diffTypesPtrOffset]; + } + return result; + } + + function createFP( + slide: FarthestPoint, + down: FarthestPoint, + k: number, + M: number + ): FarthestPoint { + if (slide && slide.y === -1 && (down && down.y === -1)) + return { y: 0, id: 0 }; + if ( + (down && down.y === -1) || + k === M || + (slide && slide.y) > (down && down.y) + 1 + ) { + const prev = slide.id; + ptr++; + routes[ptr] = prev; + routes[ptr + diffTypesPtrOffset] = ADDED; + return { y: slide.y, id: ptr }; + } else { + const prev = down.id; + ptr++; + routes[ptr] = prev; + routes[ptr + diffTypesPtrOffset] = REMOVED; + return { y: down.y + 1, id: ptr }; + } + } + + function snake<T>( + k: number, + slide: FarthestPoint, + down: FarthestPoint, + _offset: number, + A: T[], + B: T[] + ): FarthestPoint { + const M = A.length; + const N = B.length; + if (k < -N || M < k) return { y: -1, id: -1 }; + const fp = createFP(slide, down, k, M); + while (fp.y + k < M && fp.y < N && A[fp.y + k] === B[fp.y]) { + const prev = fp.id; + ptr++; + fp.id = ptr; + fp.y += 1; + routes[ptr] = prev; + routes[ptr + diffTypesPtrOffset] = COMMON; + } + return fp; + } + + while (fp[delta + offset].y < N) { + p = p + 1; + for (let k = -p; k < delta; ++k) { + fp[k + offset] = snake( + k, + fp[k - 1 + offset], + fp[k + 1 + offset], + offset, + A, + B + ); + } + for (let k = delta + p; k > delta; --k) { + fp[k + offset] = snake( + k, + fp[k - 1 + offset], + fp[k + 1 + offset], + offset, + A, + B + ); + } + fp[delta + offset] = snake( + delta, + fp[delta - 1 + offset], + fp[delta + 1 + offset], + offset, + A, + B + ); + } + return [ + ...prefixCommon.map( + (c): DiffResult<typeof c> => ({ type: DiffType.common, value: c }) + ), + ...backTrace(A, B, fp[delta + offset], swapped), + ...suffixCommon.map( + (c): DiffResult<typeof c> => ({ type: DiffType.common, value: c }) + ) + ]; +} diff --git a/std/testing/diff_test.ts b/std/testing/diff_test.ts new file mode 100644 index 000000000..d9fbdb956 --- /dev/null +++ b/std/testing/diff_test.ts @@ -0,0 +1,111 @@ +import diff from "./diff.ts"; +import { assertEquals } from "../testing/asserts.ts"; +import { test } from "./mod.ts"; + +test({ + name: "empty", + fn(): void { + assertEquals(diff([], []), []); + } +}); + +test({ + name: '"a" vs "b"', + fn(): void { + assertEquals(diff(["a"], ["b"]), [ + { type: "removed", value: "a" }, + { type: "added", value: "b" } + ]); + } +}); + +test({ + name: '"a" vs "a"', + fn(): void { + assertEquals(diff(["a"], ["a"]), [{ type: "common", value: "a" }]); + } +}); + +test({ + name: '"a" vs ""', + fn(): void { + assertEquals(diff(["a"], []), [{ type: "removed", value: "a" }]); + } +}); + +test({ + name: '"" vs "a"', + fn(): void { + assertEquals(diff([], ["a"]), [{ type: "added", value: "a" }]); + } +}); + +test({ + name: '"a" vs "a, b"', + fn(): void { + assertEquals(diff(["a"], ["a", "b"]), [ + { type: "common", value: "a" }, + { type: "added", value: "b" } + ]); + } +}); + +test({ + name: '"strength" vs "string"', + fn(): void { + assertEquals(diff(Array.from("strength"), Array.from("string")), [ + { type: "common", value: "s" }, + { type: "common", value: "t" }, + { type: "common", value: "r" }, + { type: "removed", value: "e" }, + { type: "added", value: "i" }, + { type: "common", value: "n" }, + { type: "common", value: "g" }, + { type: "removed", value: "t" }, + { type: "removed", value: "h" } + ]); + } +}); + +test({ + name: '"strength" vs ""', + fn(): void { + assertEquals(diff(Array.from("strength"), Array.from("")), [ + { type: "removed", value: "s" }, + { type: "removed", value: "t" }, + { type: "removed", value: "r" }, + { type: "removed", value: "e" }, + { type: "removed", value: "n" }, + { type: "removed", value: "g" }, + { type: "removed", value: "t" }, + { type: "removed", value: "h" } + ]); + } +}); + +test({ + name: '"" vs "strength"', + fn(): void { + assertEquals(diff(Array.from(""), Array.from("strength")), [ + { type: "added", value: "s" }, + { type: "added", value: "t" }, + { type: "added", value: "r" }, + { type: "added", value: "e" }, + { type: "added", value: "n" }, + { type: "added", value: "g" }, + { type: "added", value: "t" }, + { type: "added", value: "h" } + ]); + } +}); + +test({ + name: '"abc", "c" vs "abc", "bcd", "c"', + fn(): void { + assertEquals(diff(["abc", "c"], ["abc", "bcd", "c"]), [ + { type: "common", value: "abc" }, + { type: "added", value: "bcd" }, + { type: "common", value: "c" } + ]); + } +}); diff --git a/std/testing/format.ts b/std/testing/format.ts new file mode 100644 index 000000000..953347c27 --- /dev/null +++ b/std/testing/format.ts @@ -0,0 +1,538 @@ +// This file is ported from pretty-format@24.0.0 +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Refs = any[]; +export type Optional<T> = { [K in keyof T]?: T[K] }; + +export interface Options { + callToJSON: boolean; + escapeRegex: boolean; + escapeString: boolean; + indent: number; + maxDepth: number; + min: boolean; + printFunctionName: boolean; +} + +export interface Config { + callToJSON: boolean; + escapeRegex: boolean; + escapeString: boolean; + indent: string; + maxDepth: number; + min: boolean; + printFunctionName: boolean; + spacingInner: string; + spacingOuter: string; +} + +export type Printer = ( + val: unknown, + config: Config, + indentation: string, + depth: number, + refs: Refs, + hasCalledToJSON?: boolean +) => string; + +const toString = Object.prototype.toString; +const toISOString = Date.prototype.toISOString; +const errorToString = Error.prototype.toString; +const regExpToString = RegExp.prototype.toString; +const symbolToString = Symbol.prototype.toString; + +const DEFAULT_OPTIONS: Options = { + callToJSON: true, + escapeRegex: false, + escapeString: true, + indent: 2, + maxDepth: Infinity, + min: false, + printFunctionName: true +}; + +interface BasicValueOptions { + printFunctionName: boolean; + escapeRegex: boolean; + escapeString: boolean; +} + +/** + * Explicitly comparing typeof constructor to function avoids undefined as name + * when mock identity-obj-proxy returns the key as the value for any key. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getConstructorName = (val: new (...args: any[]) => any): string => + (typeof val.constructor === "function" && val.constructor.name) || "Object"; + +/* global window */ +/** Is val is equal to global window object? + * Works even if it does not exist :) + * */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isWindow = (val: any): val is Window => + typeof window !== "undefined" && val === window; + +const SYMBOL_REGEXP = /^Symbol\((.*)\)(.*)$/; + +function isToStringedArrayType(toStringed: string): boolean { + return ( + toStringed === "[object Array]" || + toStringed === "[object ArrayBuffer]" || + toStringed === "[object DataView]" || + toStringed === "[object Float32Array]" || + toStringed === "[object Float64Array]" || + toStringed === "[object Int8Array]" || + toStringed === "[object Int16Array]" || + toStringed === "[object Int32Array]" || + toStringed === "[object Uint8Array]" || + toStringed === "[object Uint8ClampedArray]" || + toStringed === "[object Uint16Array]" || + toStringed === "[object Uint32Array]" + ); +} + +function printNumber(val: number): string { + return Object.is(val, -0) ? "-0" : String(val); +} + +function printFunction(val: () => void, printFunctionName: boolean): string { + if (!printFunctionName) { + return "[Function]"; + } + return "[Function " + (val.name || "anonymous") + "]"; +} + +function printSymbol(val: symbol): string { + return symbolToString.call(val).replace(SYMBOL_REGEXP, "Symbol($1)"); +} + +function printError(val: Error): string { + return "[" + errorToString.call(val) + "]"; +} + +/** + * The first port of call for printing an object, handles most of the + * data-types in JS. + */ +function printBasicValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + val: any, + { printFunctionName, escapeRegex, escapeString }: BasicValueOptions +): string | null { + if (val === true || val === false) { + return String(val); + } + if (val === undefined) { + return "undefined"; + } + if (val === null) { + return "null"; + } + + const typeOf = typeof val; + + if (typeOf === "number") { + return printNumber(val); + } + if (typeOf === "string") { + if (escapeString) { + return `"${val.replace(/"|\\/g, "\\$&")}"`; + } + return `"${val}"`; + } + if (typeOf === "function") { + return printFunction(val, printFunctionName); + } + if (typeOf === "symbol") { + return printSymbol(val); + } + + const toStringed = toString.call(val); + + if (toStringed === "[object WeakMap]") { + return "WeakMap {}"; + } + if (toStringed === "[object WeakSet]") { + return "WeakSet {}"; + } + if ( + toStringed === "[object Function]" || + toStringed === "[object GeneratorFunction]" + ) { + return printFunction(val, printFunctionName); + } + if (toStringed === "[object Symbol]") { + return printSymbol(val); + } + if (toStringed === "[object Date]") { + return isNaN(+val) ? "Date { NaN }" : toISOString.call(val); + } + if (toStringed === "[object Error]") { + return printError(val); + } + if (toStringed === "[object RegExp]") { + if (escapeRegex) { + // https://github.com/benjamingr/RegExp.escape/blob/master/polyfill.js + return regExpToString.call(val).replace(/[\\^$*+?.()|[\]{}]/g, "\\$&"); + } + return regExpToString.call(val); + } + + if (val instanceof Error) { + return printError(val); + } + + return null; +} + +function printer( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + val: any, + config: Config, + indentation: string, + depth: number, + refs: Refs, + hasCalledToJSON?: boolean +): string { + const basicResult = printBasicValue(val, config); + if (basicResult !== null) { + return basicResult; + } + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return printComplexValue( + val, + config, + indentation, + depth, + refs, + hasCalledToJSON + ); +} + +/** + * Return items (for example, of an array) + * with spacing, indentation, and comma + * without surrounding punctuation (for example, brackets) + */ +function printListItems( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + list: any, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer +): string { + let result = ""; + + if (list.length) { + result += config.spacingOuter; + + const indentationNext = indentation + config.indent; + + for (let i = 0; i < list.length; i++) { + result += + indentationNext + + printer(list[i], config, indentationNext, depth, refs); + + if (i < list.length - 1) { + result += "," + config.spacingInner; + } else if (!config.min) { + result += ","; + } + } + + result += config.spacingOuter + indentation; + } + + return result; +} + +/** + * Return entries (for example, of a map) + * with spacing, indentation, and comma + * without surrounding punctuation (for example, braces) + */ +function printIteratorEntries( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iterator: any, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, + // Too bad, so sad that separator for ECMAScript Map has been ' => ' + // What a distracting diff if you change a data structure to/from + // ECMAScript Object or Immutable.Map/OrderedMap which use the default. + separator = ": " +): string { + let result = ""; + let current = iterator.next(); + + if (!current.done) { + result += config.spacingOuter; + + const indentationNext = indentation + config.indent; + + while (!current.done) { + const name = printer( + current.value[0], + config, + indentationNext, + depth, + refs + ); + const value = printer( + current.value[1], + config, + indentationNext, + depth, + refs + ); + + result += indentationNext + name + separator + value; + + current = iterator.next(); + + if (!current.done) { + result += "," + config.spacingInner; + } else if (!config.min) { + result += ","; + } + } + + result += config.spacingOuter + indentation; + } + + return result; +} + +/** + * Return values (for example, of a set) + * with spacing, indentation, and comma + * without surrounding punctuation (braces or brackets) + */ +function printIteratorValues( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + iterator: Iterator<any>, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer +): string { + let result = ""; + let current = iterator.next(); + + if (!current.done) { + result += config.spacingOuter; + + const indentationNext = indentation + config.indent; + + while (!current.done) { + result += + indentationNext + + printer(current.value, config, indentationNext, depth, refs); + + current = iterator.next(); + + if (!current.done) { + result += "," + config.spacingInner; + } else if (!config.min) { + result += ","; + } + } + + result += config.spacingOuter + indentation; + } + + return result; +} + +const getKeysOfEnumerableProperties = (object: {}): Array<string | symbol> => { + const keys: Array<string | symbol> = Object.keys(object).sort(); + + if (Object.getOwnPropertySymbols) { + Object.getOwnPropertySymbols(object).forEach((symbol): void => { + if (Object.getOwnPropertyDescriptor(object, symbol)!.enumerable) { + keys.push(symbol); + } + }); + } + + return keys; +}; + +/** + * Return properties of an object + * with spacing, indentation, and comma + * without surrounding punctuation (for example, braces) + */ +function printObjectProperties( + val: {}, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer +): string { + let result = ""; + const keys = getKeysOfEnumerableProperties(val); + + if (keys.length) { + result += config.spacingOuter; + + const indentationNext = indentation + config.indent; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const name = printer(key, config, indentationNext, depth, refs); + const value = printer( + val[key as keyof typeof val], + config, + indentationNext, + depth, + refs + ); + + result += indentationNext + name + ": " + value; + + if (i < keys.length - 1) { + result += "," + config.spacingInner; + } else if (!config.min) { + result += ","; + } + } + + result += config.spacingOuter + indentation; + } + + return result; +} + +/** + * Handles more complex objects ( such as objects with circular references. + * maps and sets etc ) + */ +function printComplexValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + val: any, + config: Config, + indentation: string, + depth: number, + refs: Refs, + hasCalledToJSON?: boolean +): string { + if (refs.indexOf(val) !== -1) { + return "[Circular]"; + } + refs = refs.slice(); + refs.push(val); + + const hitMaxDepth = ++depth > config.maxDepth; + const { min, callToJSON } = config; + + if ( + callToJSON && + !hitMaxDepth && + val.toJSON && + typeof val.toJSON === "function" && + !hasCalledToJSON + ) { + return printer(val.toJSON(), config, indentation, depth, refs, true); + } + + const toStringed = toString.call(val); + if (toStringed === "[object Arguments]") { + return hitMaxDepth + ? "[Arguments]" + : (min ? "" : "Arguments ") + + "[" + + printListItems(val, config, indentation, depth, refs, printer) + + "]"; + } + if (isToStringedArrayType(toStringed)) { + return hitMaxDepth + ? `[${val.constructor.name}]` + : (min ? "" : `${val.constructor.name} `) + + "[" + + printListItems(val, config, indentation, depth, refs, printer) + + "]"; + } + if (toStringed === "[object Map]") { + return hitMaxDepth + ? "[Map]" + : "Map {" + + printIteratorEntries( + val.entries(), + config, + indentation, + depth, + refs, + printer, + " => " + ) + + "}"; + } + if (toStringed === "[object Set]") { + return hitMaxDepth + ? "[Set]" + : "Set {" + + printIteratorValues( + val.values(), + config, + indentation, + depth, + refs, + printer + ) + + "}"; + } + + // Avoid failure to serialize global window object in jsdom test environment. + // For example, not even relevant if window is prop of React element. + return hitMaxDepth || isWindow(val) + ? "[" + getConstructorName(val) + "]" + : (min ? "" : getConstructorName(val) + " ") + + "{" + + printObjectProperties(val, config, indentation, depth, refs, printer) + + "}"; +} + +// TODO this is better done with `.padStart()` +function createIndent(indent: number): string { + return new Array(indent + 1).join(" "); +} + +const getConfig = (options: Options): Config => ({ + ...options, + indent: options.min ? "" : createIndent(options.indent), + spacingInner: options.min ? " " : "\n", + spacingOuter: options.min ? "" : "\n" +}); + +/** + * Returns a presentation string of your `val` object + * @param val any potential JavaScript object + * @param options Custom settings + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function format(val: any, options: Optional<Options> = {}): string { + const opts: Options = { + ...DEFAULT_OPTIONS, + ...options + }; + const basicResult = printBasicValue(val, opts); + if (basicResult !== null) { + return basicResult; + } + + return printComplexValue(val, getConfig(opts), "", 0, []); +} diff --git a/std/testing/format_test.ts b/std/testing/format_test.ts new file mode 100644 index 000000000..e2bb8df23 --- /dev/null +++ b/std/testing/format_test.ts @@ -0,0 +1,805 @@ +// This file is ported from pretty-format@24.0.0 +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import { test } from "./mod.ts"; +import { assertEquals } from "../testing/asserts.ts"; +import { format } from "./format.ts"; + +// eslint-disable-next-line max-len +// eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/no-explicit-any +function returnArguments(...args: any[]): IArguments { + // eslint-disable-next-line prefer-rest-params + return arguments; +} + +function MyObject(value: unknown): void { + // @ts-ignore + this.name = value; +} + +class MyArray<T> extends Array<T> {} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const createVal = () => [ + { + id: "8658c1d0-9eda-4a90-95e1-8001e8eb6036", + text: "Add alternative serialize API for pretty-format plugins", + type: "ADD_TODO" + }, + { + id: "8658c1d0-9eda-4a90-95e1-8001e8eb6036", + type: "TOGGLE_TODO" + } +]; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const createExpected = () => + [ + "Array [", + " Object {", + ' "id": "8658c1d0-9eda-4a90-95e1-8001e8eb6036",', + ' "text": "Add alternative serialize API for pretty-format plugins",', + ' "type": "ADD_TODO",', + " },", + " Object {", + ' "id": "8658c1d0-9eda-4a90-95e1-8001e8eb6036",', + ' "type": "TOGGLE_TODO",', + " },", + "]" + ].join("\n"); + +test({ + name: "prints empty arguments", + fn(): void { + const val = returnArguments(); + assertEquals(format(val), "Arguments []"); + } +}); + +test({ + name: "prints an empty array", + fn(): void { + const val: unknown[] = []; + assertEquals(format(val), "Array []"); + } +}); + +test({ + name: "prints an array with items", + fn(): void { + const val = [1, 2, 3]; + assertEquals(format(val), "Array [\n 1,\n 2,\n 3,\n]"); + } +}); + +test({ + name: "prints a empty typed array", + fn(): void { + const val = new Uint32Array(0); + assertEquals(format(val), "Uint32Array []"); + } +}); + +test({ + name: "prints a typed array with items", + fn(): void { + const val = new Uint32Array(3); + assertEquals(format(val), "Uint32Array [\n 0,\n 0,\n 0,\n]"); + } +}); + +test({ + name: "prints an array buffer", + fn(): void { + const val = new ArrayBuffer(3); + assertEquals(format(val), "ArrayBuffer []"); + } +}); + +test({ + name: "prints a nested array", + fn(): void { + const val = [[1, 2, 3]]; + assertEquals( + format(val), + "Array [\n Array [\n 1,\n 2,\n 3,\n ],\n]" + ); + } +}); + +test({ + name: "prints true", + fn(): void { + const val = true; + assertEquals(format(val), "true"); + } +}); + +test({ + name: "prints false", + fn(): void { + const val = false; + assertEquals(format(val), "false"); + } +}); + +test({ + name: "prints an error", + fn(): void { + const val = new Error(); + assertEquals(format(val), "[Error]"); + } +}); + +test({ + name: "prints a typed error with a message", + fn(): void { + const val = new TypeError("message"); + assertEquals(format(val), "[TypeError: message]"); + } +}); + +test({ + name: "prints a function constructor", + fn(): void { + // tslint:disable-next-line:function-constructor + const val = new Function(); + assertEquals(format(val), "[Function anonymous]"); + } +}); + +test({ + name: "prints an anonymous callback function", + fn(): void { + let val; + function f(cb: () => void): void { + val = cb; + } + // tslint:disable-next-line:no-empty + f((): void => {}); + assertEquals(format(val), "[Function anonymous]"); + } +}); + +test({ + name: "prints an anonymous assigned function", + fn(): void { + // tslint:disable-next-line:no-empty + const val = (): void => {}; + const formatted = format(val); + assertEquals( + formatted === "[Function anonymous]" || formatted === "[Function val]", + true + ); + } +}); + +test({ + name: "prints a named function", + fn(): void { + // tslint:disable-next-line:no-empty + const val = function named(): void {}; + assertEquals(format(val), "[Function named]"); + } +}); + +test({ + name: "prints a named generator function", + fn(): void { + const val = function* generate(): IterableIterator<number> { + yield 1; + yield 2; + yield 3; + }; + assertEquals(format(val), "[Function generate]"); + } +}); + +test({ + name: "can customize function names", + fn(): void { + // tslint:disable-next-line:no-empty + const val = function named(): void {}; + assertEquals( + format(val, { + printFunctionName: false + }), + "[Function]" + ); + } +}); + +test({ + name: "prints Infinity", + fn(): void { + const val = Infinity; + assertEquals(format(val), "Infinity"); + } +}); + +test({ + name: "prints -Infinity", + fn(): void { + const val = -Infinity; + assertEquals(format(val), "-Infinity"); + } +}); + +test({ + name: "prints an empty map", + fn(): void { + const val = new Map(); + assertEquals(format(val), "Map {}"); + } +}); + +test({ + name: "prints a map with values", + fn(): void { + const val = new Map(); + val.set("prop1", "value1"); + val.set("prop2", "value2"); + assertEquals( + format(val), + 'Map {\n "prop1" => "value1",\n "prop2" => "value2",\n}' + ); + } +}); + +test({ + name: "prints a map with non-string keys", + fn(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const val = new Map<any, any>([ + [false, "boolean"], + ["false", "string"], + [0, "number"], + ["0", "string"], + [null, "null"], + ["null", "string"], + [undefined, "undefined"], + ["undefined", "string"], + [Symbol("description"), "symbol"], + ["Symbol(description)", "string"], + [["array", "key"], "array"], + [{ key: "value" }, "object"] + ]); + const expected = [ + "Map {", + ' false => "boolean",', + ' "false" => "string",', + ' 0 => "number",', + ' "0" => "string",', + ' null => "null",', + ' "null" => "string",', + ' undefined => "undefined",', + ' "undefined" => "string",', + ' Symbol(description) => "symbol",', + ' "Symbol(description)" => "string",', + " Array [", + ' "array",', + ' "key",', + ' ] => "array",', + " Object {", + ' "key": "value",', + ' } => "object",', + "}" + ].join("\n"); + assertEquals(format(val), expected); + } +}); + +test({ + name: "prints NaN", + fn(): void { + const val = NaN; + assertEquals(format(val), "NaN"); + } +}); + +test({ + name: "prints null", + fn(): void { + const val = null; + assertEquals(format(val), "null"); + } +}); + +test({ + name: "prints a positive number", + fn(): void { + const val = 123; + assertEquals(format(val), "123"); + } +}); + +test({ + name: "prints a negative number", + fn(): void { + const val = -123; + assertEquals(format(val), "-123"); + } +}); + +test({ + name: "prints zero", + fn(): void { + const val = 0; + assertEquals(format(val), "0"); + } +}); + +test({ + name: "prints negative zero", + fn(): void { + const val = -0; + assertEquals(format(val), "-0"); + } +}); + +test({ + name: "prints a date", + fn(): void { + const val = new Date(10e11); + assertEquals(format(val), "2001-09-09T01:46:40.000Z"); + } +}); + +test({ + name: "prints an invalid date", + fn(): void { + const val = new Date(Infinity); + assertEquals(format(val), "Date { NaN }"); + } +}); + +test({ + name: "prints an empty object", + fn(): void { + const val = {}; + assertEquals(format(val), "Object {}"); + } +}); + +test({ + name: "prints an object with properties", + fn(): void { + const val = { prop1: "value1", prop2: "value2" }; + assertEquals( + format(val), + 'Object {\n "prop1": "value1",\n "prop2": "value2",\n}' + ); + } +}); + +test({ + name: "prints an object with properties and symbols", + fn(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const val: any = {}; + val[Symbol("symbol1")] = "value2"; + val[Symbol("symbol2")] = "value3"; + val.prop = "value1"; + assertEquals( + format(val), + 'Object {\n "prop": "value1",\n Symbol(symbol1): "value2",\n ' + + 'Symbol(symbol2): "value3",\n}' + ); + } +}); + +test({ + name: + "prints an object without non-enumerable properties which have string key", + fn(): void { + const val = { + enumerable: true + }; + const key = "non-enumerable"; + Object.defineProperty(val, key, { + enumerable: false, + value: false + }); + assertEquals(format(val), 'Object {\n "enumerable": true,\n}'); + } +}); + +test({ + name: + "prints an object without non-enumerable properties which have symbol key", + fn(): void { + const val = { + enumerable: true + }; + const key = Symbol("non-enumerable"); + Object.defineProperty(val, key, { + enumerable: false, + value: false + }); + assertEquals(format(val), 'Object {\n "enumerable": true,\n}'); + } +}); + +test({ + name: "prints an object with sorted properties", + fn(): void { + const val = { b: 1, a: 2 }; + assertEquals(format(val), 'Object {\n "a": 2,\n "b": 1,\n}'); + } +}); + +test({ + name: "prints regular expressions from constructors", + fn(): void { + const val = new RegExp("regexp"); + assertEquals(format(val), "/regexp/"); + } +}); + +test({ + name: "prints regular expressions from literals", + fn(): void { + const val = /regexp/gi; + assertEquals(format(val), "/regexp/gi"); + } +}); + +test({ + name: "prints regular expressions {escapeRegex: false}", + fn(): void { + const val = /regexp\d/gi; + assertEquals(format(val), "/regexp\\d/gi"); + } +}); + +test({ + name: "prints regular expressions {escapeRegex: true}", + fn(): void { + const val = /regexp\d/gi; + assertEquals(format(val, { escapeRegex: true }), "/regexp\\\\d/gi"); + } +}); + +test({ + name: "escapes regular expressions nested inside object", + fn(): void { + const obj = { test: /regexp\d/gi }; + assertEquals( + format(obj, { escapeRegex: true }), + 'Object {\n "test": /regexp\\\\d/gi,\n}' + ); + } +}); + +test({ + name: "prints an empty set", + fn(): void { + const val = new Set(); + assertEquals(format(val), "Set {}"); + } +}); + +test({ + name: "prints a set with values", + fn(): void { + const val = new Set(); + val.add("value1"); + val.add("value2"); + assertEquals(format(val), 'Set {\n "value1",\n "value2",\n}'); + } +}); + +test({ + name: "prints a string", + fn(): void { + const val = "string"; + assertEquals(format(val), '"string"'); + } +}); + +test({ + name: "prints and escape a string", + fn(): void { + const val = "\"'\\"; + assertEquals(format(val), '"\\"\'\\\\"'); + } +}); + +test({ + name: "doesn't escape string with {excapeString: false}", + fn(): void { + const val = "\"'\\n"; + assertEquals(format(val, { escapeString: false }), '""\'\\n"'); + } +}); + +test({ + name: "prints a string with escapes", + fn(): void { + assertEquals(format('"-"'), '"\\"-\\""'); + assertEquals(format("\\ \\\\"), '"\\\\ \\\\\\\\"'); + } +}); + +test({ + name: "prints a multiline string", + fn(): void { + const val = ["line 1", "line 2", "line 3"].join("\n"); + assertEquals(format(val), '"' + val + '"'); + } +}); + +test({ + name: "prints a multiline string as value of object property", + fn(): void { + const polyline = { + props: { + id: "J", + points: ["0.5,0.460", "0.5,0.875", "0.25,0.875"].join("\n") + }, + type: "polyline" + }; + const val = { + props: { + children: polyline + }, + type: "svg" + }; + assertEquals( + format(val), + [ + "Object {", + ' "props": Object {', + ' "children": Object {', + ' "props": Object {', + ' "id": "J",', + ' "points": "0.5,0.460', + "0.5,0.875", + '0.25,0.875",', + " },", + ' "type": "polyline",', + " },", + " },", + ' "type": "svg",', + "}" + ].join("\n") + ); + } +}); + +test({ + name: "prints a symbol", + fn(): void { + const val = Symbol("symbol"); + assertEquals(format(val), "Symbol(symbol)"); + } +}); + +test({ + name: "prints undefined", + fn(): void { + const val = undefined; + assertEquals(format(val), "undefined"); + } +}); + +test({ + name: "prints a WeakMap", + fn(): void { + const val = new WeakMap(); + assertEquals(format(val), "WeakMap {}"); + } +}); + +test({ + name: "prints a WeakSet", + fn(): void { + const val = new WeakSet(); + assertEquals(format(val), "WeakSet {}"); + } +}); + +test({ + name: "prints deeply nested objects", + fn(): void { + const val = { prop: { prop: { prop: "value" } } }; + assertEquals( + format(val), + 'Object {\n "prop": Object {\n "prop": Object {\n "prop": ' + + '"value",\n },\n },\n}' + ); + } +}); + +test({ + name: "prints circular references", + fn(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const val: any = {}; + val.prop = val; + assertEquals(format(val), 'Object {\n "prop": [Circular],\n}'); + } +}); + +test({ + name: "prints parallel references", + fn(): void { + const inner = {}; + const val = { prop1: inner, prop2: inner }; + assertEquals( + format(val), + 'Object {\n "prop1": Object {},\n "prop2": Object {},\n}' + ); + } +}); + +test({ + name: "default implicit: 2 spaces", + fn(): void { + assertEquals(format(createVal()), createExpected()); + } +}); + +test({ + name: "default explicit: 2 spaces", + fn(): void { + assertEquals(format(createVal(), { indent: 2 }), createExpected()); + } +}); + +// Tests assume that no strings in val contain multiple adjacent spaces! +test({ + name: "non-default: 0 spaces", + fn(): void { + const indent = 0; + assertEquals( + format(createVal(), { indent }), + createExpected().replace(/ {2}/g, " ".repeat(indent)) + ); + } +}); + +test({ + name: "non-default: 4 spaces", + fn(): void { + const indent = 4; + assertEquals( + format(createVal(), { indent }), + createExpected().replace(/ {2}/g, " ".repeat(indent)) + ); + } +}); + +test({ + name: "can customize the max depth", + fn(): void { + const v = [ + { + "arguments empty": returnArguments(), + "arguments non-empty": returnArguments("arg"), + "array literal empty": [], + "array literal non-empty": ["item"], + "extended array empty": new MyArray(), + "map empty": new Map(), + "map non-empty": new Map([["name", "value"]]), + "object literal empty": {}, + "object literal non-empty": { name: "value" }, + // @ts-ignore + "object with constructor": new MyObject("value"), + "object without constructor": Object.create(null), + "set empty": new Set(), + "set non-empty": new Set(["value"]) + } + ]; + assertEquals( + format(v, { maxDepth: 2 }), + [ + "Array [", + " Object {", + ' "arguments empty": [Arguments],', + ' "arguments non-empty": [Arguments],', + ' "array literal empty": [Array],', + ' "array literal non-empty": [Array],', + ' "extended array empty": [MyArray],', + ' "map empty": [Map],', + ' "map non-empty": [Map],', + ' "object literal empty": [Object],', + ' "object literal non-empty": [Object],', + ' "object with constructor": [MyObject],', + ' "object without constructor": [Object],', + ' "set empty": [Set],', + ' "set non-empty": [Set],', + " },", + "]" + ].join("\n") + ); + } +}); + +test({ + name: "prints objects with no constructor", + fn(): void { + assertEquals(format(Object.create(null)), "Object {}"); + } +}); + +test({ + name: "prints identity-obj-proxy with string constructor", + fn(): void { + const obj = Object.create(null); + obj.constructor = "constructor"; + const expected = [ + "Object {", // Object instead of undefined + ' "constructor": "constructor",', + "}" + ].join("\n"); + assertEquals(format(obj), expected); + } +}); + +test({ + name: "calls toJSON and prints its return value", + fn(): void { + assertEquals( + format({ + toJSON: (): unknown => ({ value: false }), + value: true + }), + 'Object {\n "value": false,\n}' + ); + } +}); + +test({ + name: "calls toJSON and prints an internal representation.", + fn(): void { + assertEquals( + format({ + toJSON: (): string => "[Internal Object]", + value: true + }), + '"[Internal Object]"' + ); + } +}); + +test({ + name: "calls toJSON only on functions", + fn(): void { + assertEquals( + format({ + toJSON: false, + value: true + }), + 'Object {\n "toJSON": false,\n "value": true,\n}' + ); + } +}); + +test({ + name: "does not call toJSON recursively", + fn(): void { + assertEquals( + format({ + toJSON: (): unknown => ({ toJSON: (): unknown => ({ value: true }) }), + value: false + }), + 'Object {\n "toJSON": [Function toJSON],\n}' + ); + } +}); + +test({ + name: "calls toJSON on Sets", + fn(): void { + const set = new Set([1]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (set as any).toJSON = (): string => "map"; + assertEquals(format(set), '"map"'); + } +}); diff --git a/std/testing/mod.ts b/std/testing/mod.ts new file mode 100644 index 000000000..3b6386d5b --- /dev/null +++ b/std/testing/mod.ts @@ -0,0 +1,422 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. + +import { + bgRed, + white, + bold, + green, + red, + gray, + yellow, + italic +} from "../fmt/colors.ts"; +export type TestFunction = () => void | Promise<void>; + +export interface TestDefinition { + fn: TestFunction; + name: string; +} + +// Replacement of the global `console` function to be in silent mode +const noop = function(): void {}; + +// Clear the current line of the console. +// see: http://ascii-table.com/ansi-escape-sequences-vt-100.php +const CLEAR_LINE = "\x1b[2K\r"; + +// Save Object of the global `console` in case of silent mode +type Console = typeof window.console; +// ref https://console.spec.whatwg.org/#console-namespace +// For historical web-compatibility reasons, the namespace object for +// console must have as its [[Prototype]] an empty object, created as if +// by ObjectCreate(%ObjectPrototype%), instead of %ObjectPrototype%. +const disabledConsole = Object.create({}) as Console; +Object.assign(disabledConsole, { + log: noop, + debug: noop, + info: noop, + dir: noop, + warn: noop, + error: noop, + assert: noop, + count: noop, + countReset: noop, + table: noop, + time: noop, + timeLog: noop, + timeEnd: noop, + group: noop, + groupCollapsed: noop, + groupEnd: noop, + clear: noop +}); + +const originalConsole = window.console; + +function enableConsole(): void { + window.console = originalConsole; +} + +function disableConsole(): void { + window.console = disabledConsole; +} + +const encoder = new TextEncoder(); +function print(txt: string, newline = true): void { + if (newline) { + txt += "\n"; + } + Deno.stdout.writeSync(encoder.encode(`${txt}`)); +} + +declare global { + interface Window { + /** + * A global property to collect all registered test cases. + * + * It is required because user's code can import multiple versions + * of `testing` module. + * + * If test cases aren't registered in a globally shared + * object, then imports from different versions would register test cases + * to registry from it's respective version of `testing` module. + */ + __DENO_TEST_REGISTRY: TestDefinition[]; + } +} + +let candidates: TestDefinition[] = []; +if (window["__DENO_TEST_REGISTRY"]) { + candidates = window.__DENO_TEST_REGISTRY as TestDefinition[]; +} else { + window["__DENO_TEST_REGISTRY"] = candidates; +} +let filterRegExp: RegExp | null; +let filtered = 0; + +// Must be called before any test() that needs to be filtered. +export function setFilter(s: string): void { + filterRegExp = new RegExp(s, "i"); +} + +function filter(name: string): boolean { + if (filterRegExp) { + return filterRegExp.test(name); + } else { + return true; + } +} + +export function test(t: TestDefinition): void; +export function test(fn: TestFunction): void; +export function test(name: string, fn: TestFunction): void; +export function test( + t: string | TestDefinition | TestFunction, + fn?: TestFunction +): void { + let name: string; + + if (typeof t === "string") { + if (!fn) { + throw new Error("Missing test function"); + } + name = t; + } else { + fn = typeof t === "function" ? t : t.fn; + name = t.name; + } + + if (!name) { + throw new Error("Test function may not be anonymous"); + } + if (filter(name)) { + candidates.push({ fn, name }); + } else { + filtered++; + } +} + +const RED_FAILED = red("FAILED"); +const GREEN_OK = green("OK"); +const RED_BG_FAIL = bgRed(" FAIL "); + +interface TestStats { + filtered: number; + ignored: number; + measured: number; + passed: number; + failed: number; +} + +interface TestResult { + timeElapsed?: number; + name: string; + error?: Error; + ok: boolean; + printed: boolean; +} + +interface TestResults { + keys: Map<string, number>; + cases: Map<number, TestResult>; +} + +function createTestResults(tests: TestDefinition[]): TestResults { + return tests.reduce( + (acc: TestResults, { name }: TestDefinition, i: number): TestResults => { + acc.keys.set(name, i); + acc.cases.set(i, { name, printed: false, ok: false, error: undefined }); + return acc; + }, + { cases: new Map(), keys: new Map() } + ); +} + +function formatTestTime(time = 0): string { + return `${time.toFixed(2)}ms`; +} + +function promptTestTime(time = 0, displayWarning = false): string { + // if time > 5s we display a warning + // only for test time, not the full runtime + if (displayWarning && time >= 5000) { + return bgRed(white(bold(`(${formatTestTime(time)})`))); + } else { + return gray(italic(`(${formatTestTime(time)})`)); + } +} + +function report(result: TestResult): void { + if (result.ok) { + print( + `${GREEN_OK} ${result.name} ${promptTestTime( + result.timeElapsed, + true + )}` + ); + } else if (result.error) { + print(`${RED_FAILED} ${result.name}\n${result.error.stack}`); + } else { + print(`test ${result.name} ... unresolved`); + } + result.printed = true; +} + +function printFailedSummary(results: TestResults): void { + results.cases.forEach((v): void => { + if (!v.ok) { + console.error(`${RED_BG_FAIL} ${red(v.name)}`); + console.error(v.error); + } + }); +} + +function printResults( + stats: TestStats, + results: TestResults, + flush: boolean, + exitOnFail: boolean, + timeElapsed: number +): void { + if (flush) { + for (const result of results.cases.values()) { + if (!result.printed) { + report(result); + if (result.error && exitOnFail) { + break; + } + } + } + } + // Attempting to match the output of Rust's test runner. + print( + `\ntest result: ${stats.failed ? RED_BG_FAIL : GREEN_OK} ` + + `${stats.passed} passed; ${stats.failed} failed; ` + + `${stats.ignored} ignored; ${stats.measured} measured; ` + + `${stats.filtered} filtered out ` + + `${promptTestTime(timeElapsed)}\n` + ); +} + +function previousPrinted(name: string, results: TestResults): boolean { + const curIndex: number = results.keys.get(name)!; + if (curIndex === 0) { + return true; + } + return results.cases.get(curIndex - 1)!.printed; +} + +async function createTestCase( + stats: TestStats, + results: TestResults, + exitOnFail: boolean, + { fn, name }: TestDefinition +): Promise<void> { + const result: TestResult = results.cases.get(results.keys.get(name)!)!; + try { + const start = performance.now(); + await fn(); + const end = performance.now(); + stats.passed++; + result.ok = true; + result.timeElapsed = end - start; + } catch (err) { + stats.failed++; + result.error = err; + if (exitOnFail) { + throw err; + } + } + if (previousPrinted(name, results)) { + report(result); + } +} + +function initTestCases( + stats: TestStats, + results: TestResults, + tests: TestDefinition[], + exitOnFail: boolean +): Array<Promise<void>> { + return tests.map(createTestCase.bind(null, stats, results, exitOnFail)); +} + +async function runTestsParallel( + stats: TestStats, + results: TestResults, + tests: TestDefinition[], + exitOnFail: boolean +): Promise<void> { + try { + await Promise.all(initTestCases(stats, results, tests, exitOnFail)); + } catch (_) { + // The error was thrown to stop awaiting all promises if exitOnFail === true + // stats.failed has been incremented and the error stored in results + } +} + +async function runTestsSerial( + stats: TestStats, + results: TestResults, + tests: TestDefinition[], + exitOnFail: boolean, + disableLog: boolean +): Promise<void> { + for (const { fn, name } of tests) { + // Displaying the currently running test if silent mode + if (disableLog) { + print(`${yellow("RUNNING")} ${name}`, false); + } + try { + const start = performance.now(); + await fn(); + const end = performance.now(); + if (disableLog) { + // Rewriting the current prompt line to erase `running ....` + print(CLEAR_LINE, false); + } + stats.passed++; + print( + GREEN_OK + " " + name + " " + promptTestTime(end - start, true) + ); + results.cases.forEach((v): void => { + if (v.name === name) { + v.ok = true; + v.printed = true; + } + }); + } catch (err) { + if (disableLog) { + print(CLEAR_LINE, false); + } + print(`${RED_FAILED} ${name}`); + print(err.stack); + stats.failed++; + results.cases.forEach((v): void => { + if (v.name === name) { + v.error = err; + v.ok = false; + v.printed = true; + } + }); + if (exitOnFail) { + break; + } + } + } +} + +/** Defines options for controlling execution details of a test suite. */ +export interface RunTestsOptions { + parallel?: boolean; + exitOnFail?: boolean; + only?: RegExp; + skip?: RegExp; + disableLog?: boolean; +} + +/** + * Runs specified test cases. + * Parallel execution can be enabled via the boolean option; default: serial. + */ +// TODO: change return type to `Promise<boolean>` - ie. don't +// exit but return value +export async function runTests({ + parallel = false, + exitOnFail = false, + only = /[^\s]/, + skip = /^\s*$/, + disableLog = false +}: RunTestsOptions = {}): Promise<void> { + const tests: TestDefinition[] = candidates.filter( + ({ name }): boolean => only.test(name) && !skip.test(name) + ); + const stats: TestStats = { + measured: 0, + ignored: candidates.length - tests.length, + filtered: filtered, + passed: 0, + failed: 0 + }; + const results: TestResults = createTestResults(tests); + print(`running ${tests.length} tests`); + const start = performance.now(); + if (Deno.args.includes("--quiet")) { + disableLog = true; + } + if (disableLog) { + disableConsole(); + } + if (parallel) { + await runTestsParallel(stats, results, tests, exitOnFail); + } else { + await runTestsSerial(stats, results, tests, exitOnFail, disableLog); + } + const end = performance.now(); + if (disableLog) { + enableConsole(); + } + printResults(stats, results, parallel, exitOnFail, end - start); + if (stats.failed) { + // Use setTimeout to avoid the error being ignored due to unhandled + // promise rejections being swallowed. + setTimeout((): void => { + console.error(`There were ${stats.failed} test failures.`); + printFailedSummary(results); + Deno.exit(1); + }, 0); + } +} + +/** + * Runs specified test cases if the enclosing script is main. + * Execution mode is toggleable via opts.parallel, defaults to false. + */ +export async function runIfMain( + meta: ImportMeta, + opts?: RunTestsOptions +): Promise<void> { + if (meta.main) { + return runTests(opts); + } +} diff --git a/std/testing/runner.ts b/std/testing/runner.ts new file mode 100755 index 000000000..a78ed2b3a --- /dev/null +++ b/std/testing/runner.ts @@ -0,0 +1,236 @@ +#!/usr/bin/env -S deno -A +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { parse } from "../flags/mod.ts"; +import { ExpandGlobOptions, expandGlob } from "../fs/mod.ts"; +import { isWindows } from "../fs/path/constants.ts"; +import { join } from "../fs/path/mod.ts"; +import { RunTestsOptions, runTests } from "./mod.ts"; +const { DenoError, ErrorKind, args, cwd, exit } = Deno; + +const DIR_GLOBS = [join("**", "?(*_)test.{js,ts}")]; + +function showHelp(): void { + console.log(`Deno test runner + +USAGE: + deno -A https://deno.land/std/testing/runner.ts [OPTIONS] [MODULES...] + +OPTIONS: + -q, --quiet Don't show output from test cases + -f, --failfast Stop running tests on first error + -e, --exclude <MODULES...> List of comma-separated modules to exclude + --allow-none Exit with status 0 even when no test modules are + found + +ARGS: + [MODULES...] List of test modules to run. + A directory <dir> will expand to: + ${DIR_GLOBS.map((s: string): string => `${join("<dir>", s)}`) + .join(` + `)} + Defaults to "." when none are provided. + +Note that modules can refer to file paths or URLs. File paths support glob +expansion. + +Examples: + deno test src/**/*_test.ts + deno test tests`); +} + +function isRemoteUrl(url: string): boolean { + return /^https?:\/\//.test(url); +} + +function partition( + arr: string[], + callback: (el: string) => boolean +): [string[], string[]] { + return arr.reduce( + (paritioned: [string[], string[]], el: string): [string[], string[]] => { + paritioned[callback(el) ? 1 : 0].push(el); + return paritioned; + }, + [[], []] + ); +} + +function filePathToUrl(path: string): string { + return `file://${isWindows ? "/" : ""}${path.replace(/\\/g, "/")}`; +} + +/** + * Given a list of globs or URLs to include and exclude and a root directory + * from which to expand relative globs, yield a list of URLs + * (file: or remote) that should be imported for the test runner. + */ +export async function* findTestModules( + includeModules: string[], + excludeModules: string[], + root: string = cwd() +): AsyncIterableIterator<string> { + const [includePaths, includeUrls] = partition(includeModules, isRemoteUrl); + const [excludePaths, excludeUrls] = partition(excludeModules, isRemoteUrl); + + const expandGlobOpts: ExpandGlobOptions = { + root, + exclude: excludePaths, + includeDirs: true, + extended: true, + globstar: true + }; + + async function* expandDirectory(d: string): AsyncIterableIterator<string> { + for (const dirGlob of DIR_GLOBS) { + for await (const walkInfo of expandGlob(dirGlob, { + ...expandGlobOpts, + root: d, + includeDirs: false + })) { + yield filePathToUrl(walkInfo.filename); + } + } + } + + for (const globString of includePaths) { + for await (const walkInfo of expandGlob(globString, expandGlobOpts)) { + if (walkInfo.info.isDirectory()) { + yield* expandDirectory(walkInfo.filename); + } else { + yield filePathToUrl(walkInfo.filename); + } + } + } + + const excludeUrlPatterns = excludeUrls.map( + (url: string): RegExp => RegExp(url) + ); + const shouldIncludeUrl = (url: string): boolean => + !excludeUrlPatterns.some((p: RegExp): boolean => !!url.match(p)); + + yield* includeUrls.filter(shouldIncludeUrl); +} + +export interface RunTestModulesOptions extends RunTestsOptions { + include?: string[]; + exclude?: string[]; + allowNone?: boolean; +} + +/** + * Import the specified test modules and run their tests as a suite. + * + * Test modules are specified as an array of strings and can include local files + * or URLs. + * + * File matching and excluding support glob syntax - arguments recognized as + * globs will be expanded using `glob()` from the `fs` module. + * + * Example: + * + * runTestModules({ include: ["**\/*_test.ts", "**\/test.ts"] }); + * + * Any matched directory `<dir>` will expand to: + * <dir>/**\/?(*_)test.{js,ts} + * + * So the above example is captured naturally by: + * + * runTestModules({ include: ["."] }); + * + * Which is the default used for: + * + * runTestModules(); + */ +// TODO: Change return type to `Promise<void>` once, `runTests` is updated +// to return boolean instead of exiting. +export async function runTestModules({ + include = ["."], + exclude = [], + allowNone = false, + parallel = false, + exitOnFail = false, + only = /[^\s]/, + skip = /^\s*$/, + disableLog = false +}: RunTestModulesOptions = {}): Promise<void> { + let moduleCount = 0; + for await (const testModule of findTestModules(include, exclude)) { + await import(testModule); + moduleCount++; + } + + if (moduleCount == 0) { + const noneFoundMessage = "No matching test modules found."; + if (!allowNone) { + throw new DenoError(ErrorKind.NotFound, noneFoundMessage); + } else if (!disableLog) { + console.log(noneFoundMessage); + } + return; + } + + if (!disableLog) { + console.log(`Found ${moduleCount} matching test modules.`); + } + + await runTests({ + parallel, + exitOnFail, + only, + skip, + disableLog + }); +} + +async function main(): Promise<void> { + const parsedArgs = parse(args.slice(1), { + boolean: ["allow-none", "failfast", "help", "quiet"], + string: ["exclude"], + alias: { + exclude: ["e"], + failfast: ["f"], + help: ["h"], + quiet: ["q"] + }, + default: { + "allow-none": false, + failfast: false, + help: false, + quiet: false + } + }); + if (parsedArgs.help) { + return showHelp(); + } + + const include = + parsedArgs._.length > 0 + ? (parsedArgs._ as string[]).flatMap((fileGlob: string): string[] => + fileGlob.split(",") + ) + : ["."]; + const exclude = + parsedArgs.exclude != null ? (parsedArgs.exclude as string).split(",") : []; + const allowNone = parsedArgs["allow-none"]; + const exitOnFail = parsedArgs.failfast; + const disableLog = parsedArgs.quiet; + + try { + await runTestModules({ + include, + exclude, + allowNone, + exitOnFail, + disableLog + }); + } catch (error) { + if (!disableLog) { + console.error(error.message); + } + exit(1); + } +} + +if (import.meta.main) { + main(); +} diff --git a/std/testing/runner_test.ts b/std/testing/runner_test.ts new file mode 100644 index 000000000..e2617b155 --- /dev/null +++ b/std/testing/runner_test.ts @@ -0,0 +1,95 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { test } from "./mod.ts"; +import { findTestModules } from "./runner.ts"; +import { isWindows } from "../fs/path/constants.ts"; +import { assertEquals } from "../testing/asserts.ts"; +const { cwd } = Deno; + +function urlToFilePath(url: URL): string { + // Since `new URL('file:///C:/a').pathname` is `/C:/a`, remove leading slash. + return url.pathname.slice(url.protocol == "file:" && isWindows ? 1 : 0); +} + +async function findTestModulesArray( + include: string[], + exclude: string[], + root: string = cwd() +): Promise<string[]> { + const result = []; + for await (const testModule of findTestModules(include, exclude, root)) { + result.push(testModule); + } + return result; +} + +const TEST_DATA_URL = new URL("testdata", import.meta.url); +const TEST_DATA_PATH = urlToFilePath(TEST_DATA_URL); + +test(async function findTestModulesDir1(): Promise<void> { + const urls = await findTestModulesArray(["."], [], TEST_DATA_PATH); + assertEquals(urls.sort(), [ + `${TEST_DATA_URL}/bar_test.js`, + `${TEST_DATA_URL}/foo_test.ts`, + `${TEST_DATA_URL}/subdir/bar_test.js`, + `${TEST_DATA_URL}/subdir/foo_test.ts`, + `${TEST_DATA_URL}/subdir/test.js`, + `${TEST_DATA_URL}/subdir/test.ts`, + `${TEST_DATA_URL}/test.js`, + `${TEST_DATA_URL}/test.ts` + ]); +}); + +test(async function findTestModulesDir2(): Promise<void> { + const urls = await findTestModulesArray(["subdir"], [], TEST_DATA_PATH); + assertEquals(urls.sort(), [ + `${TEST_DATA_URL}/subdir/bar_test.js`, + `${TEST_DATA_URL}/subdir/foo_test.ts`, + `${TEST_DATA_URL}/subdir/test.js`, + `${TEST_DATA_URL}/subdir/test.ts` + ]); +}); + +test(async function findTestModulesGlob(): Promise<void> { + const urls = await findTestModulesArray( + ["**/*_test.{js,ts}"], + [], + TEST_DATA_PATH + ); + assertEquals(urls.sort(), [ + `${TEST_DATA_URL}/bar_test.js`, + `${TEST_DATA_URL}/foo_test.ts`, + `${TEST_DATA_URL}/subdir/bar_test.js`, + `${TEST_DATA_URL}/subdir/foo_test.ts` + ]); +}); + +test(async function findTestModulesExcludeDir(): Promise<void> { + const urls = await findTestModulesArray(["."], ["subdir"], TEST_DATA_PATH); + assertEquals(urls.sort(), [ + `${TEST_DATA_URL}/bar_test.js`, + `${TEST_DATA_URL}/foo_test.ts`, + `${TEST_DATA_URL}/test.js`, + `${TEST_DATA_URL}/test.ts` + ]); +}); + +test(async function findTestModulesExcludeGlob(): Promise<void> { + const urls = await findTestModulesArray(["."], ["**/foo*"], TEST_DATA_PATH); + assertEquals(urls.sort(), [ + `${TEST_DATA_URL}/bar_test.js`, + `${TEST_DATA_URL}/subdir/bar_test.js`, + `${TEST_DATA_URL}/subdir/test.js`, + `${TEST_DATA_URL}/subdir/test.ts`, + `${TEST_DATA_URL}/test.js`, + `${TEST_DATA_URL}/test.ts` + ]); +}); + +test(async function findTestModulesRemote(): Promise<void> { + const urls = [ + "https://example.com/colors_test.ts", + "http://example.com/printf_test.ts" + ]; + const matches = await findTestModulesArray(urls, []); + assertEquals(matches, urls); +}); diff --git a/std/testing/test.ts b/std/testing/test.ts new file mode 100644 index 000000000..dd6d772f6 --- /dev/null +++ b/std/testing/test.ts @@ -0,0 +1,263 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { test, runIfMain } from "./mod.ts"; +import { + assert, + assertEquals, + assertStrictEq, + assertThrows, + assertThrowsAsync +} from "./asserts.ts"; + +test(function testingAssertEqualActualUncoercable(): void { + let didThrow = false; + const a = Object.create(null); + try { + assertEquals(a, "bar"); + } catch (e) { + didThrow = true; + } + assert(didThrow); +}); + +test(function testingAssertEqualExpectedUncoercable(): void { + let didThrow = false; + const a = Object.create(null); + try { + assertStrictEq("bar", a); + } catch (e) { + didThrow = true; + } + assert(didThrow); +}); + +test(function testingAssertStrictEqual(): void { + const a = {}; + const b = a; + assertStrictEq(a, b); +}); + +test(function testingAssertNotStrictEqual(): void { + let didThrow = false; + const a = {}; + const b = {}; + try { + assertStrictEq(a, b); + } catch (e) { + assert(e.message === "actual: [object Object] expected: [object Object]"); + didThrow = true; + } + assert(didThrow); +}); + +test(function testingDoesThrow(): void { + let count = 0; + assertThrows((): void => { + count++; + throw new Error(); + }); + assert(count === 1); +}); + +test(function testingDoesNotThrow(): void { + let count = 0; + let didThrow = false; + try { + assertThrows((): void => { + count++; + console.log("Hello world"); + }); + } catch (e) { + assert(e.message === "Expected function to throw."); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test(function testingThrowsErrorType(): void { + let count = 0; + assertThrows((): void => { + count++; + throw new TypeError(); + }, TypeError); + assert(count === 1); +}); + +test(function testingThrowsNotErrorType(): void { + let count = 0; + let didThrow = false; + try { + assertThrows((): void => { + count++; + throw new TypeError(); + }, RangeError); + } catch (e) { + assert(e.message === `Expected error to be instance of "RangeError".`); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test(function testingThrowsMsgIncludes(): void { + let count = 0; + assertThrows( + (): void => { + count++; + throw new TypeError("Hello world!"); + }, + TypeError, + "world" + ); + assert(count === 1); +}); + +test(function testingThrowsMsgNotIncludes(): void { + let count = 0; + let didThrow = false; + try { + assertThrows( + (): void => { + count++; + throw new TypeError("Hello world!"); + }, + TypeError, + "foobar" + ); + } catch (e) { + assert( + e.message === + `Expected error message to include "foobar", but got "Hello world!".` + ); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test(async function testingDoesThrowAsync(): Promise<void> { + let count = 0; + await assertThrowsAsync( + async (): Promise<void> => { + count++; + throw new Error(); + } + ); + assert(count === 1); +}); + +test(async function testingDoesReject(): Promise<void> { + let count = 0; + await assertThrowsAsync( + (): Promise<never> => { + count++; + return Promise.reject(new Error()); + } + ); + assert(count === 1); +}); + +test(async function testingDoesNotThrowAsync(): Promise<void> { + let count = 0; + let didThrow = false; + try { + await assertThrowsAsync( + async (): Promise<void> => { + count++; + console.log("Hello world"); + } + ); + } catch (e) { + assert(e.message === "Expected function to throw."); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test(async function testingDoesNotRejectAsync(): Promise<void> { + let count = 0; + let didThrow = false; + try { + await assertThrowsAsync( + (): Promise<void> => { + count++; + console.log("Hello world"); + return Promise.resolve(); + } + ); + } catch (e) { + assert(e.message === "Expected function to throw."); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test(async function testingThrowsAsyncErrorType(): Promise<void> { + let count = 0; + await assertThrowsAsync((): Promise<void> => { + count++; + throw new TypeError(); + }, TypeError); + assert(count === 1); +}); + +test(async function testingThrowsAsyncNotErrorType(): Promise<void> { + let count = 0; + let didThrow = false; + try { + await assertThrowsAsync(async (): Promise<void> => { + count++; + throw new TypeError(); + }, RangeError); + } catch (e) { + assert(e.message === `Expected error to be instance of "RangeError".`); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test(async function testingThrowsAsyncMsgIncludes(): Promise<void> { + let count = 0; + await assertThrowsAsync( + async (): Promise<void> => { + count++; + throw new TypeError("Hello world!"); + }, + TypeError, + "world" + ); + assert(count === 1); +}); + +test(async function testingThrowsAsyncMsgNotIncludes(): Promise<void> { + let count = 0; + let didThrow = false; + try { + await assertThrowsAsync( + async (): Promise<void> => { + count++; + throw new TypeError("Hello world!"); + }, + TypeError, + "foobar" + ); + } catch (e) { + assert( + e.message === + `Expected error message to include "foobar", but got "Hello world!".` + ); + didThrow = true; + } + assert(count === 1); + assert(didThrow); +}); + +test("test fn overloading", (): void => { + // just verifying that you can use this test definition syntax + assert(true); +}); + +runIfMain(import.meta); diff --git a/std/testing/testdata/bar.js b/std/testing/testdata/bar.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/bar.js @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/bar_test.js b/std/testing/testdata/bar_test.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/bar_test.js @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/foo.ts b/std/testing/testdata/foo.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/foo.ts @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/foo_test.ts b/std/testing/testdata/foo_test.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/foo_test.ts @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/subdir/bar.js b/std/testing/testdata/subdir/bar.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/subdir/bar.js @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/subdir/bar_test.js b/std/testing/testdata/subdir/bar_test.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/subdir/bar_test.js @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/subdir/foo.ts b/std/testing/testdata/subdir/foo.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/subdir/foo.ts @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/subdir/foo_test.ts b/std/testing/testdata/subdir/foo_test.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/subdir/foo_test.ts @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/subdir/test.js b/std/testing/testdata/subdir/test.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/subdir/test.js @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/subdir/test.ts b/std/testing/testdata/subdir/test.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/subdir/test.ts @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/test.js b/std/testing/testdata/test.js new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/test.js @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testdata/test.ts b/std/testing/testdata/test.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/std/testing/testdata/test.ts @@ -0,0 +1 @@ +export {}; diff --git a/std/testing/testing_bench.ts b/std/testing/testing_bench.ts new file mode 100644 index 000000000..9033e3a72 --- /dev/null +++ b/std/testing/testing_bench.ts @@ -0,0 +1,18 @@ +import { bench, runIfMain } from "./bench.ts"; +import { runTests } from "./mod.ts"; + +import "./asserts_test.ts"; + +bench(async function testingSerial(b): Promise<void> { + b.start(); + await runTests(); + b.stop(); +}); + +bench(async function testingParallel(b): Promise<void> { + b.start(); + await runTests({ parallel: true }); + b.stop(); +}); + +runIfMain(import.meta); |