Skip to content

Commit

Permalink
Add expectDocCommentIncludes assertion (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
tommy-mitchell authored Sep 14, 2022
1 parent 68acb5b commit c3d0949
Show file tree
Hide file tree
Showing 16 changed files with 159 additions and 32 deletions.
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ Asserts that the type and return type of `expression` is `never`.

Useful for checking that all branches are covered.

### expectDocCommentIncludes<T>(expression: any)

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

## Programmatic API

You can use the programmatic API to retrieve the diagnostics and do something with them. This can be useful to run the tests with AVA, Jest or any other testing framework.
Expand Down
10 changes: 10 additions & 0 deletions source/lib/assertions/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,13 @@ export const expectNever = (expression: never): never => {
export const printType = (expression: any) => {
// Do nothing, the TypeScript compiler handles this for us
};

/**
* Asserts that the documentation comment of `expression` includes string literal type `T`.
*
* @param expression - Expression whose documentation comment should include string literal type `T`.
*/
// @ts-expect-error
export const expectDocCommentIncludes = <T>(expression: any) => {
// Do nothing, the TypeScript compiler handles this for us
};
2 changes: 1 addition & 1 deletion source/lib/assertions/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ export {Handler} from './handler';
export {isIdentical, isNotIdentical, isNever} from './identicality';
export {isNotAssignable} from './assignability';
export {expectDeprecated, expectNotDeprecated} from './expect-deprecated';
export {prinTypeWarning} from './informational';
export {printTypeWarning, expectDocCommentIncludes} from './informational';
53 changes: 51 additions & 2 deletions source/lib/assertions/handlers/informational.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {CallExpression, TypeChecker, TypeFormatFlags} from '@tsd/typescript';
import {Diagnostic} from '../../interfaces';
import {makeDiagnostic} from '../../utils';
import {makeDiagnostic, tsutils} from '../../utils';

/**
* Default formatting flags set by TS plus the {@link TypeFormatFlags.NoTruncation NoTruncation} flag.
Expand All @@ -19,7 +19,7 @@ const typeToStringFormatFlags =
* @param nodes - The `printType` AST nodes.
* @return List of warning diagnostics containing the type of the first argument.
*/
export const prinTypeWarning = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
export const printTypeWarning = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
const diagnostics: Diagnostic[] = [];

if (!nodes) {
Expand All @@ -36,3 +36,52 @@ export const prinTypeWarning = (checker: TypeChecker, nodes: Set<CallExpression>

return diagnostics;
};

/**
* Asserts that the documentation comment for the argument of the assertion
* includes the string literal generic type of the assertion.
*
* @param checker - The TypeScript type checker.
* @param nodes - The `expectDocCommentIncludes` AST nodes.
* @return List of diagnostics.
*/
export const expectDocCommentIncludes = (checker: TypeChecker, nodes: Set<CallExpression>): Diagnostic[] => {
const diagnostics: Diagnostic[] = [];

if (!nodes) {
return diagnostics;
}

for (const node of nodes) {
const expression = tsutils.expressionToString(checker, node.arguments[0]) ?? '?';

if (!node.typeArguments) {
diagnostics.push(makeDiagnostic(node, `Expected documentation comment for expression \`${expression}\` not specified.`));
continue;
}

const maybeExpectedDocComment = checker.getTypeFromTypeNode(node.typeArguments[0]);

if (!maybeExpectedDocComment.isStringLiteral()) {
diagnostics.push(makeDiagnostic(node, `Expected documentation comment for expression \`${expression}\` should be a string literal.`));
continue;
}

const expectedDocComment = maybeExpectedDocComment.value;
const docComment = tsutils.resolveDocComment(checker, node.arguments[0]);

if (!docComment) {
diagnostics.push(makeDiagnostic(node, `Documentation comment for expression \`${expression}\` not found.`));
continue;
}

if (docComment.includes(expectedDocComment)) {
// Do nothing
continue;
}

diagnostics.push(makeDiagnostic(node, `Documentation comment \`${docComment}\` for expression \`${expression}\` does not include expected \`${expectedDocComment}\`.`));
}

return diagnostics;
};
7 changes: 5 additions & 2 deletions source/lib/assertions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
expectDeprecated,
expectNotDeprecated,
isNever,
prinTypeWarning,
printTypeWarning,
expectDocCommentIncludes,
} from './handlers';

export enum Assertion {
Expand All @@ -21,6 +22,7 @@ export enum Assertion {
EXPECT_NOT_DEPRECATED = 'expectNotDeprecated',
EXPECT_NEVER = 'expectNever',
PRINT_TYPE = 'printType',
EXPECT_DOC_COMMENT_INCLUDES = 'expectDocCommentIncludes',
}

// List of diagnostic handlers attached to the assertion
Expand All @@ -31,7 +33,8 @@ const assertionHandlers = new Map<Assertion, Handler>([
[Assertion.EXPECT_DEPRECATED, expectDeprecated],
[Assertion.EXPECT_NOT_DEPRECATED, expectNotDeprecated],
[Assertion.EXPECT_NEVER, isNever],
[Assertion.PRINT_TYPE, prinTypeWarning]
[Assertion.PRINT_TYPE, printTypeWarning],
[Assertion.EXPECT_DOC_COMMENT_INCLUDES, expectDocCommentIncludes],
]);

/**
Expand Down
46 changes: 35 additions & 11 deletions source/lib/utils/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo} from '@tsd/typescript';
import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo, displayPartsToString} from '@tsd/typescript';

const resolveCommentHelper = <R extends 'JSDoc' | 'DocComment'>(resolve: R) => {
type ConditionalResolveReturn = (R extends 'JSDoc' ? Map<string, JSDocTagInfo> : string) | undefined;

const handler = (checker: TypeChecker, expression: Expression): ConditionalResolveReturn => {
const ref = isCallLikeExpression(expression) ?
checker.getResolvedSignature(expression) :
checker.getSymbolAtLocation(expression);

if (!ref) {
return;
}

switch (resolve) {
case 'JSDoc':
return new Map<string, JSDocTagInfo>(ref.getJsDocTags().map(tag => [tag.name, tag])) as ConditionalResolveReturn;
case 'DocComment':
return displayPartsToString(ref.getDocumentationComment(checker)) as ConditionalResolveReturn;
default:
return undefined;
}
};

return handler;
};

/**
* Resolve the JSDoc tags from the expression. If these tags couldn't be found, it will return `undefined`.
Expand All @@ -7,17 +32,16 @@ import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo} from '@tsd/
* @param expression - The expression to resolve the JSDoc tags for.
* @return A unique Set of JSDoc tags or `undefined` if they couldn't be resolved.
*/
export const resolveJSDocTags = (checker: TypeChecker, expression: Expression): Map<string, JSDocTagInfo> | undefined => {
const ref = isCallLikeExpression(expression) ?
checker.getResolvedSignature(expression) :
checker.getSymbolAtLocation(expression);
export const resolveJSDocTags = resolveCommentHelper('JSDoc');

if (!ref) {
return;
}

return new Map<string, JSDocTagInfo>(ref.getJsDocTags().map(tag => [tag.name, tag]));
};
/**
* Resolve the documentation comment from the expression. If the comment can't be found, it will return `undefined`.
*
* @param checker - The TypeScript type checker.
* @param expression - The expression to resolve the documentation comment for.
* @return A string of the documentation comment or `undefined` if it can't be resolved.
*/
export const resolveDocComment = resolveCommentHelper('DocComment');

/**
* Convert a TypeScript expression to a string.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function (foo: number): number | null;
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {expectDocCommentIncludes} from '../../../..';

const noDocComment = 'no doc comment';

expectDocCommentIncludes<'no doc comment'>(noDocComment);

/** FooBar */
const foo = 'bar';

expectDocCommentIncludes(foo);
expectDocCommentIncludes<boolean>(foo);
expectDocCommentIncludes<'BarFoo'>(foo);
expectDocCommentIncludes<'FooBar'>(foo);
expectDocCommentIncludes<'Foo'>(foo);
expectDocCommentIncludes<'Bar'>(foo);
File renamed without changes.
3 changes: 3 additions & 0 deletions source/test/fixtures/informational/print-type/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports.default = foo => {
return foo > 0 ? foo : null;
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {printType} from '../../..';
import {printType} from '../../../..';
import {aboveZero, bigType} from '.';

printType(aboveZero);
Expand Down
3 changes: 3 additions & 0 deletions source/test/fixtures/informational/print-type/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "foo"
}
30 changes: 30 additions & 0 deletions source/test/informational.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import path from 'path';
import test from 'ava';
import {verify} from './fixtures/utils';
import tsd from '..';

test('print type', async t => {
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/informational/print-type')});

verify(t, diagnostics, [
[4, 0, 'warning', 'Type for expression `aboveZero` is: `(foo: number) => number | null`'],
[5, 0, 'warning', 'Type for expression `null` is: `null`'],
[6, 0, 'warning', 'Type for expression `undefined` is: `undefined`'],
[7, 0, 'warning', 'Type for expression `null as any` is: `any`'],
[8, 0, 'warning', 'Type for expression `null as never` is: `never`'],
[9, 0, 'warning', 'Type for expression `null as unknown` is: `unknown`'],
[10, 0, 'warning', 'Type for expression `\'foo\'` is: `"foo"`'],
[11, 0, 'warning', 'Type for expression `bigType` is: `{ prop1: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop2: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop3: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop4: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop5: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop6: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop7: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop8: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop9: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; }`'],
]);
});

test('expect doc comment includes', async t => {
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/informational/expect-doc-comment')});

verify(t, diagnostics, [
[5, 0, 'error', 'Documentation comment for expression `noDocComment` not found.'],
[10, 0, 'error', 'Expected documentation comment for expression `foo` not specified.'],
[11, 0, 'error', 'Expected documentation comment for expression `foo` should be a string literal.'],
[12, 0, 'error', 'Documentation comment `FooBar` for expression `foo` does not include expected `BarFoo`.'],
]);
});
15 changes: 0 additions & 15 deletions source/test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,21 +442,6 @@ test('allow specifying `rootDir` option in `tsconfig.json`', async t => {
verify(t, diagnostics, []);
});

test('prints the types of expressions passed to `printType` helper', async t => {
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/print-type')});

verify(t, diagnostics, [
[4, 0, 'warning', 'Type for expression `aboveZero` is: `(foo: number) => number | null`'],
[5, 0, 'warning', 'Type for expression `null` is: `null`'],
[6, 0, 'warning', 'Type for expression `undefined` is: `undefined`'],
[7, 0, 'warning', 'Type for expression `null as any` is: `any`'],
[8, 0, 'warning', 'Type for expression `null as never` is: `never`'],
[9, 0, 'warning', 'Type for expression `null as unknown` is: `unknown`'],
[10, 0, 'warning', 'Type for expression `\'foo\'` is: `"foo"`'],
[11, 0, 'warning', 'Type for expression `bigType` is: `{ prop1: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop2: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop3: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop4: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop5: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop6: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop7: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop8: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; prop9: SuperTypeWithAnExessiveLongNameThatTakesUpTooMuchSpace; }`']
]);
});

test('assertions should be identified if imported as an aliased module', async t => {
const diagnostics = await tsd({cwd: path.join(__dirname, 'fixtures/aliased/aliased-module')});

Expand Down

0 comments on commit c3d0949

Please sign in to comment.