From b4dfc21f5fdf41ec0fc9238ebb68fde1a2230e23 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Thu, 29 Feb 2024 15:10:02 -0800 Subject: [PATCH] add types --- .github/workflows/node-aught.yml | 1 + bin/import-or-require.js | 1 + bin/tape | 2 + index.d.ts | 82 +++++++++++ index.js | 48 +++++-- lib/default_stream.d.ts | 5 + lib/default_stream.js | 11 +- lib/results.d.ts | 52 +++++++ lib/results.js | 44 ++++-- lib/test.d.ts | 233 +++++++++++++++++++++++++++++++ lib/test.js | 116 ++++++++++++--- package.json | 29 +++- test/common.js | 7 + tsconfig.json | 12 ++ 14 files changed, 599 insertions(+), 44 deletions(-) create mode 100644 index.d.ts create mode 100644 lib/default_stream.d.ts create mode 100644 lib/results.d.ts create mode 100644 lib/test.d.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/node-aught.yml b/.github/workflows/node-aught.yml index 4213896b..48b6d3ce 100644 --- a/.github/workflows/node-aught.yml +++ b/.github/workflows/node-aught.yml @@ -9,3 +9,4 @@ jobs: range: '< 10' type: minors command: npm run tests-only + skip-ls-check: true diff --git a/bin/import-or-require.js b/bin/import-or-require.js index be9e8e50..3bc7cb53 100644 --- a/bin/import-or-require.js +++ b/bin/import-or-require.js @@ -4,6 +4,7 @@ const { extname: extnamePath } = require('path'); const { pathToFileURL } = require('url'); const getPackageType = require('get-package-type'); +/** @type {(file: string) => undefined | Promise} */ // eslint-disable-next-line consistent-return module.exports = function importOrRequire(file) { const ext = extnamePath(file); diff --git a/bin/tape b/bin/tape index aa8cea32..2d8d110c 100755 --- a/bin/tape +++ b/bin/tape @@ -95,6 +95,7 @@ var hasImport = require('has-dynamic-import'); var tape = require('../'); +/** @type {(hasSupport: boolean) => Promise | void} */ function importFiles(hasSupport) { if (!hasSupport) { return files.forEach(function (x) { require(x); }); @@ -104,6 +105,7 @@ function importFiles(hasSupport) { tape.wait(); + /** @type {null | undefined | Promise} */ var filesPromise = files.reduce(function (promise, file) { return promise ? promise.then(function () { return importOrRequire(file); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 00000000..1f8eb14a --- /dev/null +++ b/index.d.ts @@ -0,0 +1,82 @@ +import type { ThroughStream } from '@ljharb/through'; + +import type Test from './lib/test'; +import type Results from './lib/results'; + +declare function harnessFunction(this: ThisType, name: string, opts: tape.TestOptions, cb: tape.TestCase): Test; +declare function harnessFunction(this: ThisType, name: string, opts: tape.TestOptions): Test; +declare function harnessFunction(this: ThisType, name: string, cb: tape.TestCase): Test; +declare function harnessFunction(this: ThisType, name: string): Test; +declare function harnessFunction(this: ThisType, opts: tape.TestOptions, cb: tape.TestCase): Test; +declare function harnessFunction(this: ThisType, opts: tape.TestOptions): Test; +declare function harnessFunction(this: ThisType, cb: tape.TestCase): Test; + +declare namespace tape { + export type TestOptions = { + objectPrintDepth?: number | undefined; + skip?: boolean | undefined; + timeout?: number | undefined; + todo?: boolean | undefined; + }; + + export interface AssertOptions { + skip?: boolean | string | undefined; + todo?: boolean | string | undefined; + message?: string | undefined; + actual?: unknown; + expected?: unknown; + exiting?: boolean; + } + + export interface TestCase { + (test: Test): void | Promise; + } + + export interface StreamOptions { + objectMode?: boolean | undefined; + } + + function createStream(opts?: StreamOptions): ThroughStream; + + export type CreateStream = typeof createStream; + + export type HarnessEventHandler = (cb: Test.SyncCallback, ...rest: unknown[]) => void; + + function only(name: string, cb: tape.TestCase): void; + function only(name: string, opts: tape.TestOptions, cb: tape.TestCase): void; + function only(cb: tape.TestCase): void; + function only(opts: tape.TestOptions, cb: tape.TestCase): void; + + export type HarnessCallSignatures = typeof harnessFunction; + + export interface Harness extends HarnessCallSignatures { + run?: () => void; + only: typeof only; + _exitCode: number; + _results: Results; + _tests: Test[]; + close: () => void; + createStream: CreateStream; + onFailure: HarnessEventHandler; + onFinish: HarnessEventHandler; + } + + export type HarnessConfig = { + autoclose?: boolean; + noOnly?: boolean; + stream?: NodeJS.WritableStream | ThroughStream; + exit?: boolean; + } & StreamOptions; + + function createHarness(conf_?: HarnessConfig): Harness; + const Test: Test; + const test: typeof tape; + const skip: Test['skip']; +} + +declare function tape(this: tape.Harness, name: string, opts: tape.TestOptions, cb: tape.TestCase): Test; +declare function tape(this: tape.Harness, name: string, cb: tape.TestCase): Test; +declare function tape(this: tape.Harness, opts?: tape.TestOptions): Test; +declare function tape(this: tape.Harness, opts: tape.TestOptions, cb: tape.TestCase): Test; + +export = tape; \ No newline at end of file diff --git a/index.js b/index.js index 8076e4c9..d2beb8f7 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,36 @@ 'use strict'; var defined = require('defined'); +var through = require('@ljharb/through'); + var createDefaultStream = require('./lib/default_stream'); var Test = require('./lib/test'); var Results = require('./lib/results'); -var through = require('@ljharb/through'); var canEmitExit = typeof process !== 'undefined' && process - && typeof process.on === 'function' && process.browser !== true; + // eslint-disable-next-line no-extra-parens + && typeof process.on === 'function' && /** @type {{ browser?: boolean }} */ (process).browser !== true; var canExit = typeof process !== 'undefined' && process && typeof process.exit === 'function'; +/** @typedef {import('.')} Tape */ +/** @typedef {import('.').Harness} Harness */ +/** @typedef {import('.').HarnessConfig} HarnessConfig */ +/** @typedef {import('.').HarnessCallSignatures} HarnessCallSignatures */ +/** @typedef {import('.').TestOptions} TestOptions */ +/** @typedef {import('.').HarnessEventHandler} HarnessEventHandler */ +/** @typedef {import('.').CreateStream} CreateStream */ +/** @typedef {import('.').createHarness} CreateHarness */ +/** @typedef {import('./lib/results').Result} Result */ +/** @typedef {import('stream').Writable} WritableStream */ +/** @typedef {import('.').TestCase} TestCase */ + module.exports = (function () { var wait = false; + /** @type {undefined | Harness} */ var harness; + /** @type {(opts?: HarnessConfig) => Harness} */ function getHarness(opts) { // this override is here since tests fail via nyc if createHarness is moved upwards if (!harness) { @@ -24,6 +40,7 @@ module.exports = (function () { return harness; } + /** @type {(this: Harness, ...args: Parameters) => ReturnType} */ function lazyLoad() { // eslint-disable-next-line no-invalid-this return getHarness().apply(this, arguments); @@ -43,6 +60,7 @@ module.exports = (function () { return getHarness().only.apply(this, arguments); }; + /** @type {CreateStream} */ lazyLoad.createStream = function (opts) { var options = opts || {}; if (!harness) { @@ -66,21 +84,23 @@ module.exports = (function () { return lazyLoad; }()); +/** @type {CreateHarness} */ function createHarness(conf_) { var results = new Results({ todoIsOK: !!(process.env.TODO_IS_OK === '1') }); if (!conf_ || conf_.autoclose !== false) { results.once('done', function () { results.close(); }); } - function test(name, conf, cb) { + /** @type {HarnessCallSignatures} */ + function test(/** @type {string} */ name, /** @type {TestOptions} */ conf, /** @type {TestCase} */ cb) { var t = new Test(name, conf, cb); test._tests.push(t); (function inspectCode(st) { - st.on('test', function sub(st_) { + st.on('test', /** @type {(st: Test) => void} */ function sub(st_) { inspectCode(st_); }); - st.on('result', function (r) { + st.on('result', /** @type {(r: Result) => void} */ function (r) { if (!r.todo && !r.ok && typeof r !== 'string') { test._exitCode = 1; } }); }(t)); @@ -90,21 +110,25 @@ function createHarness(conf_) { } test._results = results; - test._tests = []; + /** @type {Test[]} */ test._tests = []; + /** @type {CreateStream} */ test.createStream = function (opts) { return results.createStream(opts); }; + /** @type {HarnessEventHandler} */ test.onFinish = function (cb) { results.on('done', cb); }; + /** @type {HarnessEventHandler} */ test.onFailure = function (cb) { results.on('fail', cb); }; var only = false; + /** @type {() => Test} */ test.only = function () { if (only) { throw new Error('there can only be one only test'); } if (conf_ && conf_.noOnly) { throw new Error('`only` tests are prohibited'); } @@ -120,6 +144,7 @@ function createHarness(conf_) { return test; } +/** @type {(conf: Omit, wait?: boolean) => Harness} */ function createExitHarness(config, wait) { var noOnly = config.noOnly; var objectMode = config.objectMode; @@ -137,9 +162,11 @@ function createExitHarness(config, wait) { if (running) { return; } running = true; var stream = harness.createStream({ objectMode: objectMode }); - var es = stream.pipe(cStream || createDefaultStream()); + // eslint-disable-next-line no-extra-parens + var es = stream.pipe(/** @type {WritableStream} */ (cStream || createDefaultStream())); if (canEmitExit && es) { // in node v0.4, `es` is `undefined` // TODO: use `err` arg? + // @ts-expect-error unused var // eslint-disable-next-line no-unused-vars es.on('error', function (err) { harness._exitCode = 1; }); } @@ -180,6 +207,7 @@ function createExitHarness(config, wait) { } module.exports.createHarness = createHarness; -module.exports.Test = Test; -module.exports.test = module.exports; // tap compat -module.exports.test.skip = Test.skip; +var moduleExports = module.exports; // this hack is needed because TS has a bug with seemingly circular exports +moduleExports.Test = Test; +moduleExports.test = module.exports; // tap compat +moduleExports.skip = Test.skip; diff --git a/lib/default_stream.d.ts b/lib/default_stream.d.ts new file mode 100644 index 00000000..8ddb205a --- /dev/null +++ b/lib/default_stream.d.ts @@ -0,0 +1,5 @@ +import type { ThroughStream } from "@ljharb/through"; + +declare function defaultStream(): ThroughStream; + +export = defaultStream; \ No newline at end of file diff --git a/lib/default_stream.js b/lib/default_stream.js index ffc2ad11..cf0f9586 100644 --- a/lib/default_stream.js +++ b/lib/default_stream.js @@ -3,11 +3,13 @@ var through = require('@ljharb/through'); var fs = require('fs'); +/** @type {import('./default_stream')} */ module.exports = function () { var line = ''; var stream = through(write, flush); return stream; + /** @type {(buf: unknown) => void} */ function write(buf) { if ( buf == null // eslint-disable-line eqeqeq @@ -16,10 +18,11 @@ module.exports = function () { flush(); return; } - for (var i = 0; i < buf.length; i++) { - var c = typeof buf === 'string' - ? buf.charAt(i) - : String.fromCharCode(buf[i]); + var b = /** @type {string | ArrayLike} */ (buf); // eslint-disable-line no-extra-parens + for (var i = 0; i < b.length; i++) { + var c = typeof b === 'string' + ? b.charAt(i) + : String.fromCharCode(b[i]); if (c === '\n') { flush(); } else { diff --git a/lib/results.d.ts b/lib/results.d.ts new file mode 100644 index 00000000..d0a1039b --- /dev/null +++ b/lib/results.d.ts @@ -0,0 +1,52 @@ +import through from '@ljharb/through'; +import type { EventEmitter } from 'events'; + +import type { StreamOptions } from '../'; +import Test = require('./test'); + +declare class Results extends EventEmitter { + constructor(options?: { todoIsOK?: boolean }); + + count: number; + fail: number; + pass: number; + tests: Test[]; + todo: number; + todoIsOK: boolean; + closed?: boolean; + + _isRunning: boolean; + _only: Test | null; + _stream: through.ThroughStream; + + close(this: Results): void; + createStream(this: Results, opts?: StreamOptions): through.ThroughStream; + only(this: Results, t: Test): void; + push(this: Results, t: Test): void; + + _watch(this: Results, t: Test): void; +} + +declare namespace Results { + export type Operator = string; + + export type Result = { + id: number; + ok: boolean; + skip: unknown; + todo: unknown; + name?: string; + operator: undefined | Operator; + objectPrintDepth?: number; + actual?: unknown; + expected?: unknown; + error?: unknown; + functionName?: string; + file?: string; + line?: number; + column?: number; + at?: string; + }; +} + +export = Results; \ No newline at end of file diff --git a/lib/results.js b/lib/results.js index 3c44b01b..e4c7f0bc 100644 --- a/lib/results.js +++ b/lib/results.js @@ -18,10 +18,19 @@ var nextTick = typeof setImmediate !== 'undefined' ? setImmediate : process.nextTick; +/** @typedef {through.ThroughStream} Stream */ +/** @typedef {{ ok: boolean, name: string, skip?: unknown, todo?: unknown, operator: unknown, objectPrintDepth?: number, expected: unknown, actual: unknown, at?: string, error?: Error, test: unknown, type: unknown}} Result */ +/** @typedef {import('./test')} Test */ +/** @typedef {import('./results')} ResultsType */ +/** @typedef {import('../').StreamOptions} StreamOptions */ +/** @typedef {import('stream').Writable} WritableStream */ + +/** @type {(str: string) => string} */ function coalesceWhiteSpaces(str) { return $replace(String(str), /\s+/g, ' '); } +/** @type {(results: ResultsType) => Test | undefined} */ function getNextTest(results) { if (!results._only) { return $shift(results.tests); @@ -37,10 +46,12 @@ function getNextTest(results) { return void undefined; } +/** @type {(str: string) => boolean} */ function invalidYaml(str) { return $exec(yamlIndicators, str) !== null; } +/** @type {(res: Result, count: number, todoIsOK?: boolean) => string} */ function encodeResult(res, count, todoIsOK) { var output = ''; output += (res.ok || (todoIsOK && res.todo) ? 'ok ' : 'not ok ') + count; @@ -76,7 +87,8 @@ function encodeResult(res, count, todoIsOK) { output += inner + 'at: ' + res.at + '\n'; } - var actualStack = res.actual && (typeof res.actual === 'object' || typeof res.actual === 'function') ? res.actual.stack : undefined; + // eslint-disable-next-line no-extra-parens + var actualStack = res.actual && (typeof res.actual === 'object' || typeof res.actual === 'function') ? /** @type {Error} */ (res.actual).stack : undefined; var errorStack = res.error && res.error.stack; var stack = defined(actualStack, errorStack); if (stack) { @@ -91,6 +103,10 @@ function encodeResult(res, count, todoIsOK) { return output; } +/** + * @constructor + * @param {{ todoIsOK?: boolean }} [options] + */ function Results(options) { if (!(this instanceof Results)) { return new Results(options); } var opts = (arguments.length > 0 ? arguments[0] : options) || {}; @@ -107,16 +123,18 @@ function Results(options) { inherits(Results, EventEmitter); +/** @type {(this: ResultsType, opts?: StreamOptions) => Stream} */ Results.prototype.createStream = function (opts) { if (!opts) { opts = {}; } var self = this; - var output; + /** @type {Stream} */ var output; var testId = 0; if (opts.objectMode) { output = through(); - self.on('_push', function ontest(t, extra) { + self.on('_push', /** @type {(t: Test, extra: unknown ) => void} */ function ontest(t, extra) { var id = testId++; t.once('prerun', function () { + /** @type {{ parent?: unknown, type: string, name: string, id: number, skip: unknown, todo: unknown }} */ var row = { type: 'test', name: t.name, @@ -124,15 +142,15 @@ Results.prototype.createStream = function (opts) { skip: t._skip, todo: t._todo }; - if (extra && hasOwn(extra, 'parent')) { + if (extra && typeof extra === 'object' && 'parent' in extra && hasOwn(extra, 'parent')) { row.parent = extra.parent; } output.queue(row); }); - t.on('test', function (st) { + t.on('test', /** @type {(st: Test) => void} */ function (st) { ontest(st, { parent: id }); }); - t.on('result', function (res) { + t.on('result', /** @type {(res: Result) => void} */ function (res) { if (res && typeof res === 'object') { res.test = id; res.type = 'assert'; @@ -147,7 +165,8 @@ Results.prototype.createStream = function (opts) { } else { output = resumer(); output.queue('TAP version 13\n'); - self._stream.pipe(output); + // eslint-disable-next-line no-extra-parens + self._stream.pipe(/** @type {WritableStream} */ (/** @type {unknown} */ (output))); } if (!this._isRunning) { @@ -168,19 +187,22 @@ Results.prototype.createStream = function (opts) { return output; }; +/** @type {import('./results').prototype.push} */ Results.prototype.push = function (t) { $push(this.tests, t); this._watch(t); this.emit('_push', t); }; +/** @type {import('./results').prototype.only} */ Results.prototype.only = function (t) { this._only = t; }; +/** @type {import('./results').prototype._watch} */ Results.prototype._watch = function (t) { var self = this; - function write(s) { self._stream.queue(s); } + /** @type {(s: string) => void} */ function write(s) { self._stream.queue(s); } t.once('prerun', function () { var premsg = ''; @@ -194,7 +216,7 @@ Results.prototype._watch = function (t) { write('# ' + premsg + coalesceWhiteSpaces(t.name) + postmsg + '\n'); }); - t.on('result', function (res) { + t.on('result', /** @type {(res: Result | string) => void} */ function (res) { if (typeof res === 'string') { write('# ' + res + '\n'); return; @@ -210,14 +232,16 @@ Results.prototype._watch = function (t) { } }); - t.on('test', function (st) { self._watch(st); }); + t.on('test', /** @type {(st: Test) => void} */ function (st) { self._watch(st); }); }; +/** @type {import('./results').prototype.close} */ Results.prototype.close = function () { var self = this; if (self.closed) { self._stream.emit('error', new Error('ALREADY CLOSED')); } self.closed = true; + /** @type {(s: string) => void} */ function write(s) { self._stream.queue(s); } write('\n1..' + self.count + '\n'); diff --git a/lib/test.d.ts b/lib/test.d.ts new file mode 100644 index 00000000..9d20dc70 --- /dev/null +++ b/lib/test.d.ts @@ -0,0 +1,233 @@ +import type { EventEmitter } from 'events'; +import type mockProperty from 'mock-property'; + +import type { + AssertOptions, + TestCase, + TestOptions, +} from '../'; +import type { + Operator, +} from './results'; + +declare class Test extends EventEmitter { + constructor(name: string, opts: TestOptions, cb: TestCase); + constructor(name: string, opts: TestOptions); + constructor(name: string, cb: Test.Callback); + constructor(name: string); + constructor(opts: TestOptions); + constructor(cb: Test.Callback); + constructor(opts: TestOptions, cb: Test.Callback); + + readable: boolean; + name: string; + assertCount: number; + pendingCount: number; + calledEnd?: boolean; + ended: boolean; + + // "private" properties + _cb: Test.Callback | undefined; + _objectPrintDepth: number | undefined; + _ok: boolean; + _plan: number | undefined; + _planError: boolean | undefined; + _progeny: Test[]; + _skip: boolean | undefined; + _teardown: Test.Callback[]; + _timeout: number | undefined; + _todo: boolean | undefined; + + captureFn(this: Test, original: X): Test.WrappedFn; + capture(this: Test, obj: object, method: PropertyKey, implementation?: T): Test.WrapResults; + end(this: Test, err?: unknown): void; + fail(this: Test, msg: string, extra?: AssertOptions): void; + intercept(this: Test, obj: object, property: PropertyKey, desc?: PropertyDescriptor): Test.InterceptResults; + pass(this: Test, msg: string, extra?: AssertOptions): void; + run(this: Test): void; + skip(this: Test, msg: string, extra?: AssertOptions): void; + timeoutAfter(this: Test, ms: number): void; + plan(this: Test, n: number): void; + comment(this: Test, msg: string): void; + teardown(this: Test, fn: Test.Callback): void; + test(this: Test, name: string, cb: Test.Callback): void; + test(this: Test, name: string, opts: TestOptions, cb: Test.Callback): void; + + // assertions + + ok(this: Test, value: unknown, msg: string, extra?: AssertOptions): void; + true: typeof this.ok; + assert: typeof this.ok; + + notOK(this: Test, value: unknown, msg: string, extra?: AssertOptions): void; + false: typeof this.notOK; + notok: typeof this.notOK; + + error(this: Test, err?: unknown, msg?: string, extra?: AssertOptions): void; + ifError: typeof this.error; + ifErr: typeof this.error; + iferror: typeof this.error; + + equal(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + equals: typeof this.equal; + isEqual: typeof this.equal; + is: typeof this.equal; + strictEqual: typeof this.equal; + strictEquals: typeof this.equal; + + notEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + notEquals: typeof this.notEqual; + notStrictEqual: typeof this.notEqual; + notStrictEquals: typeof this.notEqual; + isNotEqual: typeof this.notEqual; + isNot: typeof this.notEqual; + not: typeof this.notEqual; + doesNotEqual: typeof this.notEqual; + isInequal: typeof this.notEqual; + + looseEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + looseEquals: typeof this.looseEqual; + + notLooseEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + notLooseEquals: typeof this.notLooseEqual; + + deepEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + deepEquals: typeof this.deepEqual; + isEquivalent: typeof this.deepEqual; + same: typeof this.deepEqual; + + notDeepEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + notDeepEquals: typeof this.notDeepEqual; + notEquivalent: typeof this.notDeepEqual; + notDeeply: typeof this.notDeepEqual; + notSame: typeof this.notDeepEqual; + isNotDeepEqual: typeof this.notDeepEqual; + isNotDeeply: typeof this.notDeepEqual; + isNotEquivalent: typeof this.notDeepEqual; + isInequivalent: typeof this.notDeepEqual; + + deepLooseEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + + notDeepLooseEqual(this: Test, a: unknown, b: unknown, msg: string, extra?: AssertOptions): void; + + throws( + this: Test, + fn: () => void, + exceptionExpected: RegExp | Function | Error | string | undefined, + msg: string, + extra?: AssertOptions, + ): void; + throws( + this: Test, + fn: () => void, + msg: string, + extra?: AssertOptions, + ): void; + + doesNotThrow( + this: Test, + fn: () => void, + exceptionExpected: RegExp | Function | undefined, + msg: string, + extra?: AssertOptions, + ): void; + doesNotThrow( + this: Test, + fn: () => void, + msg: string, + extra?: AssertOptions, + ): void; + + match( + this: Test, + actual: string, + expected: RegExp, + msg: string, + extra?: AssertOptions, + ): void; + + doesNotMatch( + this: Test, + actual: string, + expected: RegExp, + msg: string, + extra?: AssertOptions, + ): void; + + static skip( + name: string, + opts: TestOptions, + cb: Test.Callback, + ): Test; + + // "private" methods + + _assert( + this: Test, + maybeOK: boolean | unknown, + opts: TestOptions & { + message?: string; + operator?: Operator; + error?: unknown; + actual?: unknown; + expected?: unknown; + extra?: AssertOptions & { + operator?: Operator; + error?: unknown; + }; + }, + ): void; + _end(this: Test, err?: unknown): void; + _exit(this: Test): void; + _pendingAsserts(this: Test): number; +} + +declare namespace Test { + export type SyncCallback = (...args: unknown[]) => unknown; + export type Callback = (...args: unknown[]) => unknown | Promise; + + export type ReturnCall = { + args: unknown[]; + receiver: {}; + returned: unknown; + }; + + export type ThrowCall = { + args: unknown[]; + receiver: {}; + threw: true; + }; + + export type Call = { + type: 'get' | 'set'; + success: boolean; + value: unknown; + args: unknown[]; + receiver: unknown; + } + + export type RestoreFunction = Exclude, undefined>; + + export type WrapResults = { + (): WrappedCall[]; + restore?: RestoreFunction; + }; + + export type WrappedFn = { (): T; calls?: WrappedCall[] }; + + export type WrapObject = { + __proto__: null; + wrapped: WrappedFn; + calls: WrappedCall[]; + results: Test.WrapResults; + }; + + export type WrappedCall = ReturnCall | ThrowCall; + + export type InterceptResults = { + (): Call[]; + restore: RestoreFunction; + } +} + +export = Test; diff --git a/lib/test.js b/lib/test.js index 377a890c..eeee3ec9 100644 --- a/lib/test.js +++ b/lib/test.js @@ -35,11 +35,19 @@ var nextTick = typeof setImmediate !== 'undefined' var safeSetTimeout = setTimeout; var safeClearTimeout = clearTimeout; +/** @typedef {((c: unknown) => c is ErrorConstructor | TypeErrorConstructor | RangeErrorConstructor | EvalErrorConstructor | URIErrorConstructor | ReferenceErrorConstructor | SyntaxErrorConstructor)} IsErrorConstructor */ +/** @typedef {import('../').TestOptions} TestOptions */ +/** @typedef {import('./test').Callback} Callback */ +/** @typedef {import('./test').WrappedCall} WrappedCall */ +/** @typedef {import('./results').Result} Result */ +/** @typedef {import('./test').Call} Call */ +/** @typedef {import('./test')} TestType */ + var isErrorConstructor = isProto(Error, TypeError) // IE 8 is `false` here - ? function isErrorConstructor(C) { + ? /** @type {IsErrorConstructor} */ function isErrorConstructor(C) { return isProto(Error, C); } - : function isErrorConstructor(C) { + : /** @type {IsErrorConstructor} */ function isErrorConstructor(C) { return isProto(Error, C) || isProto(TypeError, C) || isProto(RangeError, C) @@ -49,8 +57,9 @@ var isErrorConstructor = isProto(Error, TypeError) // IE 8 is `false` here || isProto(URIError, C); }; -// eslint-disable-next-line no-unused-vars -function getTestArgs(name_, opts_, cb_) { +/** @type {(name_: string, opts_: TestOptions, cb_: Callback) => { name: string, opts: TestOptions, cb: Callback }} */ +// @ts-expect-error unused var +function getTestArgs(name_, opts_, cb_) { // eslint-disable-line no-unused-vars var name = '(anonymous)'; var opts = {}; var cb; @@ -72,12 +81,18 @@ function getTestArgs(name_, opts_, cb_) { }; } -function Test(name_, opts_, cb_) { +/** + * @constructor + * @param {string} name + * @param {TestOptions} opts + * @param {Callback} cb + */ +function Test(name, opts, cb) { if (!(this instanceof Test)) { - return new Test(name_, opts_, cb_); + return new Test(name, opts, cb); } - var args = getTestArgs(name_, opts_, cb_); + var args = getTestArgs(name, opts, cb); this.readable = true; this.name = args.name || '(anonymous)'; @@ -100,12 +115,13 @@ function Test(name_, opts_, cb_) { if (toLowerCase(depthEnvVar) === 'infinity') { this._objectPrintDepth = Infinity; } else { - this._objectPrintDepth = depthEnvVar; + this._objectPrintDepth = Number(depthEnvVar); } } for (var prop in this) { if (typeof this[prop] === 'function') { + // @ts-expect-error TODO: FIXME this[prop] = callBind(this[prop], this); } } @@ -113,6 +129,7 @@ function Test(name_, opts_, cb_) { inherits(Test, EventEmitter); +/** @type {import('./test').prototype.run} */ Test.prototype.run = function run() { this.emit('prerun'); if (!this._cb || this._skip) { @@ -128,7 +145,8 @@ Test.prototype.run = function run() { if ( typeof Promise === 'function' && callbackReturn - && typeof callbackReturn.then === 'function' + // eslint-disable-next-line no-extra-parens + && typeof /** @type {PromiseLike} */ (callbackReturn).then === 'function' ) { var self = this; Promise.resolve(callbackReturn).then( @@ -152,9 +170,11 @@ Test.prototype.run = function run() { this.emit('run'); }; +/** @type {import('./test').prototype.test} */ Test.prototype.test = function test(name, opts, cb) { var self = this; - var t = new Test(name, opts, cb); + // eslint-disable-next-line no-extra-parens + var t = /** @type {TestType} */ (/** @type {unknown} */ (new Test(name, opts, cb))); $push(this._progeny, t); this.pendingCount++; this.emit('test', t); @@ -175,18 +195,21 @@ Test.prototype.test = function test(name, opts, cb) { }); }; +/** @type {import('./test').prototype.comment} */ Test.prototype.comment = function comment(msg) { var that = this; - forEach($split(trim(msg), '\n'), function (aMsg) { + forEach($split(trim(msg), '\n'), /** @type {(aMsg: string) => void} */ function (aMsg) { that.emit('result', $replace(trim(aMsg), /^#\s*/, '')); }); }; +/** @type {import('./test').prototype.plan} */ Test.prototype.plan = function plan(n) { this._plan = n; this.emit('plan', n); }; +/** @type {import('./test').prototype.timeoutAfter} */ Test.prototype.timeoutAfter = function timeoutAfter(ms) { if (!ms) { throw new Error('timeoutAfter requires a timespan'); } var self = this; @@ -199,6 +222,7 @@ Test.prototype.timeoutAfter = function timeoutAfter(ms) { }); }; +/** @type {import('./test').prototype.end} */ Test.prototype.end = function end(err) { if (arguments.length >= 1 && !!err) { this.ifError(err); @@ -211,6 +235,7 @@ Test.prototype.end = function end(err) { this._end(); }; +/** @type {import('./test').prototype.teardown} */ Test.prototype.teardown = function teardown(fn) { if (typeof fn !== 'function') { this.fail('teardown: ' + inspect(fn) + ' is not a function'); @@ -219,6 +244,7 @@ Test.prototype.teardown = function teardown(fn) { } }; +/** @type {(original: undefined | T) => import('./test').WrapObject} */ function wrapFunction(original) { if (typeof original !== 'undefined' && typeof original !== 'function') { throw new TypeError('`original` must be a function or `undefined`'); @@ -226,14 +252,17 @@ function wrapFunction(original) { var bound = original && callBind.apply(original); + /** @type {WrappedCall[]} */ var calls = []; + /** @type {import('./test').WrapObject>} */ var wrapObject = { __proto__: null, - wrapped: function wrapped() { + wrapped: /** @type {() => ReturnType} */ function wrapped() { var args = $slice(arguments); var completed = false; try { + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/57164 var returned = bound ? bound(this, arguments) : void undefined; $push(calls, { args: args, receiver: this, returned: returned }); completed = true; @@ -257,6 +286,7 @@ function wrapFunction(original) { return wrapObject; } +/** @type {import('./test').prototype.capture} */ Test.prototype.capture = function capture(obj, method) { if (!obj || (typeof obj !== 'object' && typeof obj !== 'function')) { throw new TypeError('`obj` must be an object'); @@ -264,6 +294,7 @@ Test.prototype.capture = function capture(obj, method) { if (typeof method !== 'string' && typeof method !== 'symbol') { throw new TypeError('`method` must be a string or a symbol'); } + /** @type {Parameters[0]} */ var implementation = arguments.length > 2 ? arguments[2] : void undefined; if (typeof implementation !== 'undefined' && typeof implementation !== 'function') { throw new TypeError('`implementation`, if provided, must be a function'); @@ -278,6 +309,7 @@ Test.prototype.capture = function capture(obj, method) { return wrapper.results; }; +/** @type {import('./test').prototype.captureFn} */ Test.prototype.captureFn = function captureFn(original) { if (typeof original !== 'function') { throw new TypeError('`original` must be a function'); @@ -288,6 +320,7 @@ Test.prototype.captureFn = function captureFn(original) { return wrapObject.wrapped; }; +/** @type {import('./test').prototype.intercept} */ Test.prototype.intercept = function intercept(obj, property) { if (!obj || (typeof obj !== 'object' && typeof obj !== 'function')) { throw new TypeError('`obj` must be an object'); @@ -295,6 +328,7 @@ Test.prototype.intercept = function intercept(obj, property) { if (typeof property !== 'string' && typeof property !== 'symbol') { throw new TypeError('`property` must be a string or a symbol'); } + /** @type {PropertyDescriptor} */ var desc = arguments.length > 2 ? arguments[2] : { __proto__: null }; if (typeof desc !== 'undefined' && (!desc || typeof desc !== 'object')) { throw new TypeError('`desc`, if provided, must be an object'); @@ -312,19 +346,23 @@ Test.prototype.intercept = function intercept(obj, property) { throw new TypeError('`strictMode`, if provided, must be a boolean'); } + /** @type {Call[]} */ var calls = []; var getter = desc.get && callBind.apply(desc.get); var setter = desc.set && callBind.apply(desc.set); var value = !isAccessor ? desc.value : void undefined; var writable = !!desc.writable; + /** @type {(this: T, ...args: unknown[]) => unknown} */ function getInterceptor() { + /** @type {unknown[]} */ var args = $slice(arguments); if (isAccessor) { if (getter) { var completed = false; try { - var returned = getter(this, arguments); + // eslint-disable-next-line no-extra-parens + var returned = getter(this, /** @type {readonly []} */ (/** @type {unknown} */ (arguments))); completed = true; $push(calls, { type: 'get', success: true, value: returned, args: args, receiver: this }); return returned; @@ -339,11 +377,13 @@ Test.prototype.intercept = function intercept(obj, property) { return value; } + /** @type {(this: T, v: unknown) => unknown} */ function setInterceptor(v) { var args = $slice(arguments); if (isAccessor && setter) { var completed = false; try { + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/57164 var returned = setter(this, arguments); completed = true; $push(calls, { type: 'set', success: true, value: v, args: args, receiver: this }); @@ -385,6 +425,7 @@ Test.prototype.intercept = function intercept(obj, property) { return results; }; +/** @type {import('./test').prototype._end} */ Test.prototype._end = function _end(err) { var self = this; @@ -417,15 +458,19 @@ Test.prototype._end = function _end(err) { completeEnd(); return; } - var fn = self._teardown.shift(); + var fn = /** @type {Callback} */ (self._teardown.shift()); // eslint-disable-line no-extra-parens var res; try { res = fn(); } catch (e) { + // @ts-expect-error `e` will be stringified self.fail(e); } - if (res && typeof res.then === 'function') { - res.then(next, function (_err) { + // eslint-disable-next-line no-extra-parens + if (res && typeof /** @type {PromiseLike} */ (res).then === 'function') { + /** @type {PromiseLike} */ + // eslint-disable-next-line no-extra-parens + (res).then(next, /** @type {(_err: unknown) => void} */ function (_err) { // TODO: wth? err = err || _err; }); @@ -437,6 +482,7 @@ Test.prototype._end = function _end(err) { next(); }; +/** @type {import('./test').prototype._exit} */ Test.prototype._exit = function _exit() { if (this._plan !== undefined && !this._planError && this.assertCount !== this._plan) { this._planError = true; @@ -452,6 +498,7 @@ Test.prototype._exit = function _exit() { } }; +/** @type {import('./test').prototype._pendingAsserts} */ Test.prototype._pendingAsserts = function _pendingAsserts() { if (this._plan === undefined) { return 1; @@ -459,6 +506,7 @@ Test.prototype._pendingAsserts = function _pendingAsserts() { return this._plan - (this._progeny.length + this.assertCount); }; +/** @type {import('./test').prototype._assert} */ Test.prototype._assert = function assert(ok, opts) { var self = this; var extra = opts.extra || {}; @@ -471,6 +519,7 @@ Test.prototype._assert = function assert(ok, opts) { return; } + /** @type {Result} */ var res = { id: self.assertCount++, ok: actualOK, @@ -591,11 +640,13 @@ Test.prototype._assert = function assert(ok, opts) { self._planError = true; self.fail('plan != count', { expected: self._plan, - actual: self._plan - pendingAsserts + // eslint-disable-next-line no-extra-parens + actual: /** @type {number} */ (self._plan) - pendingAsserts }); } }; +/** @type {import('./test').prototype.fail} */ Test.prototype.fail = function fail(msg, extra) { this._assert(false, { message: msg, @@ -604,6 +655,7 @@ Test.prototype.fail = function fail(msg, extra) { }); }; +/** @type {import('./test').prototype.pass} */ Test.prototype.pass = function pass(msg, extra) { this._assert(true, { message: msg, @@ -612,6 +664,7 @@ Test.prototype.pass = function pass(msg, extra) { }); }; +/** @type {import('./test').prototype.skip} */ Test.prototype.skip = function skip(msg, extra) { this._assert(true, { message: msg, @@ -621,6 +674,7 @@ Test.prototype.skip = function skip(msg, extra) { }); }; +/** @type {import('./test').prototype.ok} */ var testAssert = function assert(value, msg, extra) { // eslint-disable-line func-style this._assert(value, { message: defined(msg, 'should be truthy'), @@ -635,6 +689,7 @@ Test.prototype.ok = Test.prototype.assert = testAssert; +/** @type {import('./test').prototype.notOK} */ function notOK(value, msg, extra) { this._assert(!value, { message: defined(msg, 'should be falsy'), @@ -649,6 +704,7 @@ Test.prototype.notOk = Test.prototype.notok = notOK; +/** @type {import('./test').prototype.error} */ function error(err, msg, extra) { this._assert(!err, { message: defined(msg, String(err)), @@ -663,6 +719,7 @@ Test.prototype.error = Test.prototype.iferror = error; +/** @type {import('./test').prototype.equal} */ function strictEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -683,6 +740,7 @@ Test.prototype.equal = Test.prototype.is = strictEqual; +/** @type {import('./test').prototype.notEqual} */ function notStrictEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -707,6 +765,7 @@ Test.prototype.notEqual = Test.prototype.not = notStrictEqual; +/** @type {import('./test').prototype.looseEqual} */ function looseEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -724,6 +783,7 @@ Test.prototype.looseEqual = Test.prototype.looseEquals = looseEqual; +/** @type {import('./test').prototype.notLooseEqual} */ function notLooseEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -740,6 +800,7 @@ Test.prototype.notLooseEqual = Test.prototype.notLooseEquals = notLooseEqual; +/** @type {import('./test').prototype.deepEqual} */ function tapeDeepEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -758,6 +819,7 @@ Test.prototype.deepEqual = Test.prototype.same = tapeDeepEqual; +/** @type {import('./test').prototype.notDeepEqual} */ function notDeepEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -781,6 +843,7 @@ Test.prototype.notDeepEqual = Test.prototype.isInequivalent = notDeepEqual; +/** @type {import('./test').prototype.deepLooseEqual} */ function deepLooseEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -797,6 +860,7 @@ function deepLooseEqual(a, b, msg, extra) { Test.prototype.deepLooseEqual = deepLooseEqual; +/** @type {import('./test').prototype.notDeepLooseEqual} */ function notDeepLooseEqual(a, b, msg, extra) { if (arguments.length < 2) { throw new TypeError('two arguments must be provided to compare'); @@ -812,18 +876,21 @@ function notDeepLooseEqual(a, b, msg, extra) { Test.prototype.notDeepLooseEqual = notDeepLooseEqual; +/** @type {import('./test').prototype.throws} */ Test.prototype['throws'] = function (fn, expected, msg, extra) { if (typeof expected === 'string') { msg = expected; expected = undefined; } + /** @type {undefined | { error: unknown | Error }} */ var caught; try { fn(); } catch (err) { caught = { error: err }; + // @ts-expect-error TS doesn't understand that `Object(err) === err` narrows `err` to `object` if (Object(err) === err && 'message' in err && (!isEnumerable(err, 'message') || !hasOwn(err, 'message'))) { try { var message = err.message; @@ -833,10 +900,12 @@ Test.prototype['throws'] = function (fn, expected, msg, extra) { } } + /** @type {typeof caught | boolean} */ var passed = caught; if (caught) { - if (typeof expected === 'string' && caught.error && caught.error.message === expected) { + // eslint-disable-next-line no-extra-parens + if (typeof expected === 'string' && caught.error && /** @type {Error} */ (caught.error).message === expected) { throw new TypeError('The "error/message" argument is ambiguous. The error message ' + inspect(expected) + ' is identical to the message.'); } if (typeof expected === 'function') { @@ -859,10 +928,13 @@ Test.prototype['throws'] = function (fn, expected, msg, extra) { } else if (keys.length === 0) { throw new TypeError('`throws` validation object must not be empty'); } - passed = every(keys, function (key) { + // TS TODO: `caught.error` and `expected` should both be `object` here + passed = every(keys, /** @type {(key: PropertyKey) => boolean} */ function (key) { + // @ts-expect-error `caught-error` and `expected` are already narrowed to `object` if (typeof caught.error[key] === 'string' && isRegExp(expected[key]) && $exec(expected[key], caught.error[key]) !== null) { return true; } + // @ts-expect-error `caught-error` and `expected` are already narrowed to `object` if (key in caught.error && deepEqual(caught.error[key], expected[key], { strict: true })) { return true; } @@ -884,6 +956,7 @@ Test.prototype['throws'] = function (fn, expected, msg, extra) { }); }; +/** @type {import('./test').prototype.doesNotThrow} */ Test.prototype.doesNotThrow = function doesNotThrow(fn, expected, msg, extra) { if (typeof expected === 'string') { msg = expected; @@ -905,6 +978,7 @@ Test.prototype.doesNotThrow = function doesNotThrow(fn, expected, msg, extra) { }); }; +/** @type {import('./test').prototype.match} */ Test.prototype.match = function match(string, regexp, msg, extra) { if (!isRegExp(regexp)) { this._assert(false, { @@ -938,6 +1012,7 @@ Test.prototype.match = function match(string, regexp, msg, extra) { } }; +/** @type {import('./test').prototype.doesNotMatch} */ Test.prototype.doesNotMatch = function doesNotMatch(string, regexp, msg, extra) { if (!isRegExp(regexp)) { this._assert(false, { @@ -971,10 +1046,13 @@ Test.prototype.doesNotMatch = function doesNotMatch(string, regexp, msg, extra) } }; +/** @type {import('./test').skip} */ +// @ts-expect-error unused var // eslint-disable-next-line no-unused-vars Test.skip = function skip(name_, _opts, _cb) { var args = getTestArgs.apply(null, arguments); args.opts.skip = true; + // @ts-expect-error TODO FIXME: no idea why TS errors here return new Test(args.name, args.opts, args.cb); }; diff --git a/package.json b/package.json index a60ae3f5..8114c881 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,32 @@ "string.prototype.trim": "^1.2.8" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.15.1", "@ljharb/eslint-config": "^21.1.0", + "@ljharb/tsconfig": "^0.2.0", + "@types/array.prototype.every": "^1.1.1", + "@types/array.prototype.find": "^2.2.0", + "@types/array.prototype.flatmap": "^1.2.6", + "@types/call-bind": "^1.0.5", + "@types/concat-stream": "^2.0.3", + "@types/deep-equal": "^1.0.4", + "@types/defined": "^1.0.2", + "@types/eslint": "~8.4", + "@types/falafel": "^2.2.2", + "@types/for-each": "^0.3.3", + "@types/get-package-type": "^0.1.0", + "@types/glob": "^8.1.0", + "@types/inherits": "^0.0.33", + "@types/is-regex": "^1.0.2", + "@types/js-yaml": "^4.0.9", + "@types/minimist": "^1.2.5", + "@types/mock-property": "^1.0.2", + "@types/object-inspect": "^1.8.4", + "@types/object-is": "^1.1.0", + "@types/object-keys": "^1.0.3", + "@types/resolve": "^1.20.6", + "@types/string.prototype.trim": "^1.2.0", + "@types/tape": "^5.6.4", "array.prototype.flatmap": "^1.3.2", "aud": "^2.0.4", "auto-changelog": "^2.4.0", @@ -61,7 +86,8 @@ "nyc": "^10.3.2", "safe-publish-latest": "^2.0.0", "tap": "^8.0.1", - "tap-parser": "^5.4.0" + "tap-parser": "^5.4.0", + "typescript": "next" }, "scripts": { "prepack": "npmignore --auto --commentLines=autogenerated", @@ -74,6 +100,7 @@ "eclint:windows": "eclint check *.js", "prelint": "npm-run-posix-or-windows eclint", "lint": "eslint --ext .js,.cjs,.mjs . bin/*", + "postlint": "tsc -P . && attw -P", "pretest": "npm run lint", "test": "npm-run-posix-or-windows tests-only", "posttest": "aud --production", diff --git a/test/common.js b/test/common.js index 92784335..4f53db90 100644 --- a/test/common.js +++ b/test/common.js @@ -5,6 +5,10 @@ var spawn = require('child_process').spawn; var concat = require('concat-stream'); var yaml = require('js-yaml'); +/** @typedef {import('../lib/result').Result} Result */ +/** @typedef {import('../lib/test').SyncCallback} SyncCallback */ + +/** @type {(body: string, includeStack: boolean) => Result} */ module.exports.getDiag = function (body, includeStack) { var yamlStart = body.indexOf(' ---'); var yamlEnd = body.indexOf(' ...\n'); @@ -38,6 +42,7 @@ module.exports.getDiag = function (body, includeStack) { // strip out all stack frames that aren't directly under our test directory, // and replace them with placeholders. +/** @type {(line: string) => null | string} */ var stripChangingData = function (line) { var withoutTestDir = line.replace(__dirname, '$TEST'); var withoutPackageDir = withoutTestDir.replace(path.dirname(__dirname), '$TAPE'); @@ -62,6 +67,7 @@ var stripChangingData = function (line) { }; module.exports.stripChangingData = stripChangingData; +/** @type {(output: string) => string} */ module.exports.stripFullStack = function (output) { var stripped = ' [... stack stripped ...]'; var withDuplicates = output.split(/\r?\n/g).map(stripChangingData).map(function (line) { @@ -97,6 +103,7 @@ module.exports.stripFullStack = function (output) { ).split(/\r?\n/g); }; +/** @type {(folderName: string, fileName: string, cb: SyncCallback) => void} */ module.exports.runProgram = function (folderName, fileName, cb) { var result = { stdout: null, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..d58f8b76 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@ljharb/tsconfig", + "compilerOptions": { + "target": "ES2021", + "strictBindCallApply": false, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + }, + "exclude": [ + "coverage/**", + "example/**", + "test/**/*", + ], +}