Skip to content

Commit

Permalink
Update CLI documentation and add --typings and --files flags (#158)
Browse files Browse the repository at this point in the history
  • Loading branch information
tommy-mitchell committed Sep 14, 2022
1 parent c3d0949 commit e251852
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 53 deletions.
134 changes: 93 additions & 41 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## Install

```sh
npm install tsd
npm install --save-dev tsd
```

## Overview
Expand All @@ -14,6 +14,16 @@ This tool lets you write tests for your type definitions (i.e. your `.d.ts` file

These `.test-d.ts` files will not be executed, and not even compiled in the standard way. Instead, these files will be parsed for special constructs such as `expectError<Foo>(bar)` and then statically analyzed against your type definitions.

The `tsd` CLI will search for the main `.d.ts` file in the current or specified directory, and test it with any `.test-d.ts` files in either the same directory or a test sub-directory (default: `test-d`):

```sh
[npx] tsd [path]
```

Use `tsd --help` for usage information. See [Order of Operations](#order-of-operations) for more details on how `tsd` finds and executes tests.

*Note: the CLI is primarily used to test an entire project, not a specific file. For more specific configuration and advanced usage, see [Configuration](#configuration) and [Programmatic API](#programmatic-api).*

## Usage

Let's assume we wrote a `index.d.ts` type definition for our concat module.
Expand Down Expand Up @@ -106,9 +116,81 @@ expectType<string>(await concat('foo', 'bar'));
expectError(await concat(true, false));
```

### Test directory
## Order of Operations

When searching for `.test-d.ts` files and executing them, `tsd` does the following:

1. Locates the project's `package.json`, which needs to be in the current or specified directory (e.g. `/path/to/project` or `process.cwd()`). Fails if none is found.

2. Finds a `.d.ts` file, checking to see if one was specified manually or in the `types` field of the `package.json`. If neither is found, attempts to find one in the project directory named the same as the `main` field of the `package.json` or `index.d.ts`. Fails if no `.d.ts` file is found.

3. Finds `.test-d.ts` and `.test-d.tsx` files, which can either be in the project's root directory, a [specific folder](#test-directory) (by default `/[project-root]/test-d`), or specified individually [programatically](#testfiles) or via [the CLI](#via-the-cli). Fails if no test files are found.

4. Runs the `.test-d.ts` files through the TypeScript compiler and statically analyzes them for errors.

5. Checks the errors against [assertions](#assertions) and reports any mismatches.

## Assertions

### expectType&lt;T&gt;(expression: T)

Asserts that the type of `expression` is identical to type `T`.

### expectNotType&lt;T&gt;(expression: any)

Asserts that the type of `expression` is not identical to type `T`.

### expectAssignable&lt;T&gt;(expression: T)

Asserts that the type of `expression` is assignable to type `T`.

### expectNotAssignable&lt;T&gt;(expression: any)

Asserts that the type of `expression` is not assignable to type `T`.

### expectError&lt;T = any&gt;(expression: T)

Asserts that `expression` throws an error.

### expectDeprecated(expression: any)

Asserts that `expression` is marked as [`@deprecated`](https://jsdoc.app/tags-deprecated.html).

### expectNotDeprecated(expression: any)

Asserts that `expression` is not marked as [`@deprecated`](https://jsdoc.app/tags-deprecated.html).

### printType(expression: any)

Prints the type of `expression` as a warning.

Useful if you don't know the exact type of the expression passed to `printType()` or the type is too complex to write out by hand.

### expectNever(expression: never)

Asserts that the type and return type of `expression` is `never`.

Useful for checking that all branches are covered.

### expectDocCommentIncludes&lt;T&gt;(expression: any)

Asserts that the documentation comment of `expression` includes string literal type `T`.

## Configuration

`tsd` is designed to be used with as little configuration as possible. However, if you need a bit more control, a project's `package.json` and the `tsd` CLI offer a limited set of configurations.

For more advanced use cases (such as integrating `tsd` with testing frameworks), see [Programmatic API](#programmatic-api).

### Via `package.json`

`tsd` uses a project's `package.json` to find types and test files as well as for some configuration. It must exist in the path given to `tsd`.

For more information on how `tsd` finds a `package.json`, see [Order of Operations](#order-of-operations).

When you have spread your tests over multiple files, you can store all those files in a test directory called `test-d`. If you want to use another directory name, you can change it in `package.json`.
#### Test Directory

When you have spread your tests over multiple files, you can store all those files in a test directory called `test-d`. If you want to use another directory name, you can change it in your project's `package.json`:

```json
{
Expand All @@ -121,7 +203,7 @@ When you have spread your tests over multiple files, you can store all those fil

Now you can put all your test files in the `my-test-dir` directory.

### Custom TypeScript config
#### Custom TypeScript Config

By default, `tsd` applies the following configuration:

Expand Down Expand Up @@ -157,51 +239,21 @@ These options will be overridden if a `tsconfig.json` file is found in your proj

*Default options will apply if you don't override them explicitly.* You can't override the `moduleResolution` option.

## Assertions
### Via the CLI

### expectType&lt;T&gt;(expression: T)
The `tsd` CLI is designed to test a whole project at once, and as such only offers a couple of flags for configuration.

Asserts that the type of `expression` is identical to type `T`.

### expectNotType&lt;T&gt;(expression: any)
Alias: `-t`

Asserts that the type of `expression` is not identical to type `T`.
Path to the type definition file you want to test. Same as [`typingsFile`](#typingsfile).

### expectAssignable&lt;T&gt;(expression: T)
#### --files

Asserts that the type of `expression` is assignable to type `T`.

### expectNotAssignable&lt;T&gt;(expression: any)

Asserts that the type of `expression` is not assignable to type `T`.

### expectError&lt;T = any&gt;(expression: T)
Alias: `-f`

Asserts that `expression` throws an error.

### expectDeprecated(expression: any)

Asserts that `expression` is marked as [`@deprecated`](https://jsdoc.app/tags-deprecated.html).

### expectNotDeprecated(expression: any)

Asserts that `expression` is not marked as [`@deprecated`](https://jsdoc.app/tags-deprecated.html).

### printType(expression: any)

Prints the type of `expression` as a warning.

Useful if you don't know the exact type of the expression passed to `printType()` or the type is too complex to write out by hand.

### expectNever(expression: never)

Asserts that the type and return type of `expression` is `never`.

Useful for checking that all branches are covered.

### expectDocCommentIncludes&lt;T&gt;(expression: any)

Asserts that the documentation comment of `expression` includes string literal type `T`.
An array of test files with their path. Same as [`testFiles`](#testfiles).

## Programmatic API

Expand Down
32 changes: 30 additions & 2 deletions source/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,46 @@ const cli = meow(`
Usage
$ tsd [path]
The given directory must contain a package.json and a typings file.
Info
--help Display help text
--version Display version info
Options
--typings -t Type definition file to test [Default: "types" property in package.json]
--files -f Glob of files to test [Default: '/path/test-d/**/*.test-d.ts' or '.tsx']
Examples
$ tsd /path/to/project
$ tsd --files /test/some/folder/*.ts --files /test/other/folder/*.tsx
$ tsd
index.test-d.ts
✖ 10:20 Argument of type string is not assignable to parameter of type number.
`);
`, {
flags: {
typings: {
type: 'string',
alias: 't',
},
files: {
type: 'string',
alias: 'f',
isMultiple: true,
},
},
});

(async () => {
try {
const options = cli.input.length > 0 ? {cwd: cli.input[0]} : undefined;
const cwd = cli.input.length > 0 ? cli.input[0] : process.cwd();
const typingsFile = cli.flags.typings;
const testFiles = cli.flags.files;

const options = {cwd, typingsFile, testFiles};

const diagnostics = await tsd(options);

Expand Down
12 changes: 7 additions & 5 deletions source/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ const findTypingsFile = async (pkg: PackageJsonWithTsdConfig, options: Options):
pkg.typings ||
(pkg.main && path.parse(pkg.main).name + '.d.ts') ||
'index.d.ts';
const typingsExist = await pathExists(path.join(options.cwd, typings));

const typingsPath = path.join(options.cwd, typings);
const typingsExist = await pathExists(typingsPath);

if (!typingsExist) {
throw new Error(`The type definition \`${typings}\` does not exist. Create one and try again.`);
throw new Error(`The type definition \`${typings}\` does not exist at \`${typingsPath}\`. Is the path correct? Create one and try again.`);
}

return typings;
Expand All @@ -41,7 +43,7 @@ const findCustomTestFiles = async (testFilesPattern: readonly string[], cwd: str
const testFiles = await globby(testFilesPattern, {cwd});

if (testFiles.length === 0) {
throw new Error('Could not find any test files. Create one and try again');
throw new Error('Could not find any test files with the given pattern(s). Create one and try again.');
}

return testFiles.map(file => path.join(cwd, file));
Expand All @@ -63,7 +65,7 @@ const findTestFiles = async (typingsFilePath: string, options: Options & {config
const testDirExists = await pathExists(path.join(options.cwd, testDir));

if (testFiles.length === 0 && !testDirExists) {
throw new Error(`The test file \`${testFile}\` or \`${tsxTestFile}\` does not exist. Create one and try again.`);
throw new Error(`The test file \`${testFile}\` or \`${tsxTestFile}\` does not exist in \`${options.cwd}\`. Create one and try again.`);
}

if (testFiles.length === 0) {
Expand All @@ -82,7 +84,7 @@ export default async (options: Options = {cwd: process.cwd()}): Promise<Diagnost
const pkgResult = await readPkgUp({cwd: options.cwd});

if (!pkgResult) {
throw new Error('No `package.json` file found. Make sure you are running the command in a Node.js project.');
throw new Error(`No \`package.json\` file found in \`${options.cwd}\`. Make sure you are running the command in a Node.js project.`);
}

const pkg = pkgResult.packageJson as PackageJsonWithTsdConfig;
Expand Down
63 changes: 63 additions & 0 deletions source/test/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'path';
import test from 'ava';
import execa from 'execa';
import readPkgUp from 'read-pkg-up';

interface ExecaError extends Error {
readonly exitCode: number;
Expand Down Expand Up @@ -32,3 +33,65 @@ test('provide a path', async t => {
t.is(exitCode, 1);
t.regex(stderr, /5:19[ ]{2}Argument of type number is not assignable to parameter of type string./);
});

test('cli help flag', async t => {
const {exitCode} = await execa('dist/cli.js', ['--help']);

t.is(exitCode, 0);
});

test('cli version flag', async t => {
const pkg = readPkgUp.sync({normalize: false})?.packageJson ?? {};

const {exitCode, stdout} = await execa('dist/cli.js', ['--version']);

t.is(exitCode, 0);
t.is(stdout, pkg.version);
});

test('cli typings flag', async t => {
const runTest = async (arg: '--typings' | '-t') => {
const {exitCode, stderr} = await t.throwsAsync<ExecaError>(execa('../../../cli.js', [arg, 'utils/index.d.ts'], {
cwd: path.join(__dirname, 'fixtures/typings-custom-dir')
}));

t.is(exitCode, 1);
t.true(stderr.includes('✖ 5:19 Argument of type number is not assignable to parameter of type string.'));
};

await runTest('--typings');
await runTest('-t');
});

test('cli files flag', async t => {
const runTest = async (arg: '--files' | '-f') => {
const {exitCode, stderr} = await t.throwsAsync<ExecaError>(execa('../../../cli.js', [arg, 'unknown.test.ts'], {
cwd: path.join(__dirname, 'fixtures/specify-test-files')
}));

t.is(exitCode, 1);
t.true(stderr.includes('✖ 5:19 Argument of type number is not assignable to parameter of type string.'));
};

await runTest('--files');
await runTest('-f');
});

test('cli files flag array', async t => {
const {exitCode, stderr} = await t.throwsAsync<ExecaError>(execa('../../../cli.js', ['--files', 'unknown.test.ts', '--files', 'second.test.ts'], {
cwd: path.join(__dirname, 'fixtures/specify-test-files')
}));

t.is(exitCode, 1);
t.true(stderr.includes('✖ 5:19 Argument of type number is not assignable to parameter of type string.'));
});

test('cli typings and files flags', async t => {
const typingsFile = 'dist/test/fixtures/typings-custom-dir/utils/index.d.ts';
const testFile = 'dist/test/fixtures/typings-custom-dir/index.test-d.ts';

const {exitCode, stderr} = t.throws<ExecaError>(() => execa.commandSync(`dist/cli.js -t ${typingsFile} -f ${testFile}`));

t.is(exitCode, 1);
t.true(stderr.includes('✖ 5:19 Argument of type number is not assignable to parameter of type string.'));
});
4 changes: 4 additions & 0 deletions source/test/fixtures/specify-test-files/second.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {expectType} from '../../..';
import one from '.';

expectType<number>(one(1, 1));
17 changes: 12 additions & 5 deletions source/test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import tsd from '..';
import {Diagnostic} from '../lib/interfaces';

test('throw if no type definition was found', async t => {
await t.throwsAsync(tsd({cwd: path.join(__dirname, 'fixtures/no-tsd')}), {message: 'The type definition `index.d.ts` does not exist. Create one and try again.'});
const cwd = path.join(__dirname, 'fixtures/no-tsd');
const index = path.join(cwd, 'index.d.ts');

await t.throwsAsync(tsd({cwd}), {message: `The type definition \`index.d.ts\` does not exist at \`${index}\`. Is the path correct? Create one and try again.`});
});

test('throw if no test is found', async t => {
await t.throwsAsync(tsd({cwd: path.join(__dirname, 'fixtures/no-test')}), {message: 'The test file `index.test-d.ts` or `index.test-d.tsx` does not exist. Create one and try again.'});
const cwd = path.join(__dirname, 'fixtures/no-test');
await t.throwsAsync(tsd({cwd}), {message: `The test file \`index.test-d.ts\` or \`index.test-d.tsx\` does not exist in \`${cwd}\`. Create one and try again.`});
});

test('return diagnostics', async t => {
Expand Down Expand Up @@ -365,7 +369,8 @@ test('specify test files manually', async t => {
const diagnostics = await tsd({
cwd: path.join(__dirname, 'fixtures/specify-test-files'),
testFiles: [
'unknown.test.ts'
'unknown.test.ts',
'second.test.ts'
]
});

Expand All @@ -375,12 +380,14 @@ test('specify test files manually', async t => {
});

test('fails if typings file is not found in the specified path', async t => {
const cwd = path.join(__dirname, 'fixtures/typings-custom-dir');

const error = await t.throwsAsync(tsd({
cwd: path.join(__dirname, 'fixtures/typings-custom-dir'),
cwd,
typingsFile: 'unknown.d.ts'
}));

t.is(error.message, 'The type definition `unknown.d.ts` does not exist. Create one and try again.');
t.is(error.message, `The type definition \`unknown.d.ts\` does not exist at \`${path.join(cwd, 'unknown.d.ts')}\`. Is the path correct? Create one and try again.`);
});

test('includes extended config files along with found ones', async t => {
Expand Down

0 comments on commit e251852

Please sign in to comment.