Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add expectDocCommentIncludes assertion #155

Merged
merged 12 commits into from Sep 14, 2022
4 changes: 4 additions & 0 deletions readme.md
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
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
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
@@ -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
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
@@ -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
@@ -0,0 +1 @@
export default function (foo: number): number | null;
@@ -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);
3 changes: 3 additions & 0 deletions source/test/fixtures/informational/print-type/index.js
@@ -0,0 +1,3 @@
module.exports.default = foo => {
return foo > 0 ? foo : null;
};
@@ -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
@@ -0,0 +1,3 @@
{
"name": "foo"
}
30 changes: 30 additions & 0 deletions source/test/informational.ts
@@ -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
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