diff --git a/source/cli.ts b/source/cli.ts index ccf8ad0a..db028426 100644 --- a/source/cli.ts +++ b/source/cli.ts @@ -15,13 +15,15 @@ const cli = meow(` 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'] + --files -f Glob or source 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 + $ tsc foo.ts --module 'none' --outFile '/dev/stdout' | xargs -I{} tsd --files "foo.js:{}" + $ tsd index.test-d.ts @@ -43,12 +45,9 @@ const cli = meow(` (async () => { try { 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 {typings: typingsFile, files: testFiles} = cli.flags; - const diagnostics = await tsd(options); + const diagnostics = await tsd({cwd, typingsFile, testFiles}); if (diagnostics.length > 0) { throw new Error(formatter(diagnostics)); diff --git a/source/lib/compiler.ts b/source/lib/compiler.ts index 91f69ef1..46eac074 100644 --- a/source/lib/compiler.ts +++ b/source/lib/compiler.ts @@ -1,11 +1,13 @@ import { flattenDiagnosticMessageText, createProgram, + Program, Diagnostic as TSDiagnostic } from '@tsd/typescript'; import {ExpectedError, extractAssertions, parseErrorAssertionToLocation} from './parser'; import {Diagnostic, DiagnosticCode, Context, Location} from './interfaces'; import {handle} from './assertions'; +import {createTsProgram} from './utils/typescript'; // List of diagnostic codes that should be ignored in general const ignoredDiagnostics = new Set([ @@ -94,16 +96,14 @@ const ignoreDiagnostic = ( }; /** - * Get a list of TypeScript diagnostics within the current context. + * Get a list of TypeScript diagnostics for a given program. * - * @param context - The context object. + * @param program - A TypeScript program. * @returns List of diagnostics */ -export const getDiagnostics = (context: Context): Diagnostic[] => { +const getDiagnostics = (program: Program): Diagnostic[] => { const diagnostics: Diagnostic[] = []; - const program = createProgram(context.testFiles, context.config.compilerOptions); - const tsDiagnostics = program .getSemanticDiagnostics() .concat(program.getSyntacticDiagnostics()); @@ -163,3 +163,25 @@ export const getDiagnostics = (context: Context): Diagnostic[] => { return diagnostics; }; + +/** + * Get a list of TypeScript diagnostics within the current context. + * + * @param context - The context object. + * @returns List of diagnostics + */ +export const getAllDiagnostics = (context: Context): Diagnostic[] => { + const globProgram = createProgram(context.testFiles.globs, context.config.compilerOptions); + const globDiagnostics = getDiagnostics(globProgram); + + if (context.testFiles.sourceFiles === undefined) { + return globDiagnostics; + } + + const customProgram = createTsProgram(context.testFiles.sourceFiles, context.config.compilerOptions); + + return [ + ...globDiagnostics, + ...getDiagnostics(customProgram), + ]; +}; diff --git a/source/lib/index.ts b/source/lib/index.ts index 9aad2485..ea0eb5ec 100644 --- a/source/lib/index.ts +++ b/source/lib/index.ts @@ -2,15 +2,16 @@ import path from 'path'; import readPkgUp from 'read-pkg-up'; import pathExists from 'path-exists'; import globby from 'globby'; -import {getDiagnostics as getTSDiagnostics} from './compiler'; +import {getAllDiagnostics as getTSDiagnostics} from './compiler'; import loadConfig from './config'; import getCustomDiagnostics from './rules'; -import {Context, Config, Diagnostic, PackageJsonWithTsdConfig} from './interfaces'; +import {Context, Config, Diagnostic, PackageJsonWithTsdConfig, TestFiles} from './interfaces'; +import {getGlobTestFiles, getTextTestFiles} from './utils/filter-test-files'; export interface Options { cwd: string; typingsFile?: string; - testFiles?: readonly string[]; + testFiles?: TestFiles; } const findTypingsFile = async (pkg: PackageJsonWithTsdConfig, options: Options): Promise => { @@ -39,14 +40,19 @@ const normalizeTypingsFilePath = (typingsFilePath: string, options: Options) => return typingsFilePath; }; -const findCustomTestFiles = async (testFilesPattern: readonly string[], cwd: string) => { - const testFiles = await globby(testFilesPattern, {cwd}); +const findCustomTestFiles = async (testFilesPattern: TestFiles, cwd: string) => { + const globFiles = (await globby(getGlobTestFiles(testFilesPattern), {cwd})).map(file => path.join(cwd, file)); + const textFiles = getTextTestFiles(testFilesPattern); - if (testFiles.length === 0) { - throw new Error('Could not find any test files with the given pattern(s). Create one and try again.'); + if (textFiles.length === 0) { + if (globFiles.length === 0) { + throw new Error('Could not find any test files with the given pattern(s). Create one and try again.'); + } + + return {globs: globFiles}; } - return testFiles.map(file => path.join(cwd, file)); + return {globs: globFiles, sourceFiles: textFiles}; }; const findTestFiles = async (typingsFilePath: string, options: Options & {config: Config}) => { @@ -72,7 +78,7 @@ const findTestFiles = async (typingsFilePath: string, options: Options & {config testFiles = await globby([`${testDir}/**/*.ts`, `${testDir}/**/*.tsx`], {cwd: options.cwd}); } - return testFiles.map(fileName => path.join(options.cwd, fileName)); + return {globs: testFiles.map(fileName => path.join(options.cwd, fileName))}; }; /** diff --git a/source/lib/interfaces.ts b/source/lib/interfaces.ts index 261148e5..92ed1c94 100644 --- a/source/lib/interfaces.ts +++ b/source/lib/interfaces.ts @@ -14,11 +14,23 @@ export type PackageJsonWithTsdConfig = NormalizedPackageJson & { tsd?: RawConfig; }; +export type TestFiles = ReadonlyArray<( + | string + | {name: string; text: string} +)>; + +export type SourceFiles = ReadonlyArray<{name: string; text: string}>; + +export type ParsedTestFiles = { + globs: readonly string[]; + sourceFiles?: SourceFiles; +}; + export interface Context { cwd: string; pkg: PackageJsonWithTsdConfig; typingsFile: string; - testFiles: string[]; + testFiles: ParsedTestFiles; config: Config; } diff --git a/source/lib/utils/filter-test-files.ts b/source/lib/utils/filter-test-files.ts new file mode 100644 index 00000000..0df83af4 --- /dev/null +++ b/source/lib/utils/filter-test-files.ts @@ -0,0 +1,22 @@ +import type {TestFiles} from '../interfaces'; + +// https://regex101.com/r/8JO8Wb/1 +const regex = /^(?[^\s:]+\.[^\s:]+):(?.*)$/s; + +export const getGlobTestFiles = (testFilesPattern: TestFiles) => { + return testFilesPattern.filter(file => typeof file === 'string' && !regex.test(file)) as readonly string[]; +}; + +export const getTextTestFiles = (testFilesPattern: TestFiles) => { + return [ + ...(testFilesPattern + .filter(file => typeof file !== 'string') + ), + ...(testFilesPattern + .filter(file => typeof file === 'string') + .map(file => regex.exec(file as string)) + .filter(Boolean) + .map(match => ({name: match?.groups?.name, text: match?.groups?.text})) + ), + ] as ReadonlyArray<{name: string; text: string}>; +}; diff --git a/source/lib/utils/typescript.ts b/source/lib/utils/typescript.ts index 7cc126c8..54f20ff1 100644 --- a/source/lib/utils/typescript.ts +++ b/source/lib/utils/typescript.ts @@ -1,4 +1,17 @@ -import {TypeChecker, Expression, isCallLikeExpression, JSDocTagInfo, displayPartsToString} from '@tsd/typescript'; +import { + TypeChecker, + Expression, + isCallLikeExpression, + JSDocTagInfo, + displayPartsToString, + createProgram, + createSourceFile, + ScriptTarget, + createCompilerHost, + CompilerOptions, + CompilerHost +} from '@tsd/typescript'; +import {SourceFiles} from '../interfaces'; const resolveCommentHelper = (resolve: R) => { type ConditionalResolveReturn = (R extends 'JSDoc' ? Map : string) | undefined; @@ -69,3 +82,33 @@ export const expressionToString = (checker: TypeChecker, expression: Expression) return checker.symbolToString(symbol, expression); }; + +export const createTsProgram = (testFiles: SourceFiles, options: CompilerOptions) => { + const sourceFiles = testFiles?.map(({name, text}) => createSourceFile(name, text, ScriptTarget.Latest)); + + const defaultCompilerHost = createCompilerHost({}); + + const customCompilerHost: CompilerHost = { + getSourceFile: (name, languageVersion) => { + for (const sourceFile of sourceFiles) { + if (sourceFile.fileName === name) { + return sourceFile; + } + } + + return defaultCompilerHost.getSourceFile(name, languageVersion); + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + writeFile: (_filename, _data) => {}, + getDefaultLibFileName: () => 'lib.d.ts', + useCaseSensitiveFileNames: () => false, + getCanonicalFileName: filename => filename, + getCurrentDirectory: () => '', + getNewLine: () => '\n', + getDirectories: () => [], + fileExists: () => true, + readFile: () => '' + }; + + return createProgram(testFiles.map(file => file.name), options, customCompilerHost); +}; diff --git a/source/test/cli.ts b/source/test/cli.ts index 32ef44f2..cdf85b9f 100644 --- a/source/test/cli.ts +++ b/source/test/cli.ts @@ -1,4 +1,5 @@ -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'ava'; import execa from 'execa'; import readPkgUp from 'read-pkg-up'; @@ -14,7 +15,7 @@ test('fail if errors are found', async t => { })); t.is(exitCode, 1); - t.regex(stderr, /5:19[ ]{2}Argument of type number is not assignable to parameter of type string./); + t.true(stderr.includes('✖ 5:19 Argument of type number is not assignable to parameter of type string.')); }); test('succeed if no errors are found', async t => { @@ -31,7 +32,7 @@ test('provide a path', async t => { const {exitCode, stderr} = await t.throwsAsync(execa('dist/cli.js', [file])); t.is(exitCode, 1); - t.regex(stderr, /5:19[ ]{2}Argument of type number is not assignable to parameter of type string./); + t.true(stderr.includes('✖ 5:19 Argument of type number is not assignable to parameter of type string.')); }); test('cli help flag', async t => { @@ -51,9 +52,11 @@ test('cli version flag', async t => { test('cli typings flag', async t => { const runTest = async (arg: '--typings' | '-t') => { - const {exitCode, stderr} = await t.throwsAsync(execa('../../../cli.js', [arg, 'utils/index.d.ts'], { - cwd: path.join(__dirname, 'fixtures/typings-custom-dir') - })); + const {exitCode, stderr} = await t.throwsAsync( + 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.')); @@ -65,9 +68,11 @@ test('cli typings flag', async t => { test('cli files flag', async t => { const runTest = async (arg: '--files' | '-f') => { - const {exitCode, stderr} = await t.throwsAsync(execa('../../../cli.js', [arg, 'unknown.test.ts'], { - cwd: path.join(__dirname, 'fixtures/specify-test-files') - })); + const {exitCode, stderr} = await t.throwsAsync( + 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.')); @@ -78,9 +83,11 @@ test('cli files flag', async t => { }); test('cli files flag array', async t => { - const {exitCode, stderr} = await t.throwsAsync(execa('../../../cli.js', ['--files', 'unknown.test.ts', '--files', 'second.test.ts'], { - cwd: path.join(__dirname, 'fixtures/specify-test-files') - })); + const {exitCode, stderr} = await t.throwsAsync( + 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.')); @@ -105,3 +112,40 @@ test('tsd logs stacktrace on failure', async t => { t.true(stderr.includes('Error running tsd: JSONError: Unexpected end of JSON input while parsing empty string')); t.truthy(stack); }); + +test('pass string files', async t => { + const source = await fs.promises.readFile(path.join(__dirname, 'fixtures/specify-test-files/syntax.test.ts'), 'utf8'); + const {exitCode, stderr} = await t.throwsAsync( + execa('../../../cli.js', ['--files', `syntax.test.ts:${source}`], { + cwd: path.join(__dirname, 'fixtures/specify-test-files') + }) + ); + + t.is(exitCode, 1); + t.true(stderr.includes('✖ 1:6 Type string is not assignable to type number.')); +}); + +test('pass string files with globs', async t => { + const source = await fs.promises.readFile(path.join(__dirname, 'fixtures/specify-test-files/syntax.test.ts'), 'utf8'); + const {exitCode, stderr} = await t.throwsAsync( + execa('../../../cli.js', ['--files', 'unknown.test.ts', '--files', 'second.test.ts', '--files', `syntax.test.ts:${source}`], { + cwd: path.join(__dirname, 'fixtures/specify-test-files') + }) + ); + + const expectedLines = [ + 'unknown.test.ts:5:19', + '✖ 5:19 Argument of type number is not assignable to parameter of type string.', + '', + 'syntax.test.ts:1:6', + '✖ 1:6 Type string is not assignable to type number.', + '', + '2 errors', + ]; + + // Grab output only and skip stack trace + const receivedLines = stderr.trim().split('\n').slice(1, 1 + expectedLines.length).map(line => line.trim()); + + t.is(exitCode, 1); + t.deepEqual(receivedLines, expectedLines); +}); diff --git a/source/test/fixtures/files-js/index.d.ts b/source/test/fixtures/files-js/index.d.ts new file mode 100644 index 00000000..1beae940 --- /dev/null +++ b/source/test/fixtures/files-js/index.d.ts @@ -0,0 +1,9 @@ +export type Foo = { + a: number; + b: string; +}; + +export type Bar = { + a: string; + b: number; +}; diff --git a/source/test/fixtures/files-js/index.test-d.js b/source/test/fixtures/files-js/index.test-d.js new file mode 100644 index 00000000..9f16937c --- /dev/null +++ b/source/test/fixtures/files-js/index.test-d.js @@ -0,0 +1,17 @@ +import {expectType, expectError} from '../../..'; + +/** @type {import('.').Foo} */ +let foo = { + a: 1, + b: '2', +}; + +expectType(foo); + +expectError(foo = { + a: '1', + b: 2, +}); + +// '')' expected.' +expectError(; diff --git a/source/test/fixtures/files-js/package.json b/source/test/fixtures/files-js/package.json new file mode 100644 index 00000000..62a73d07 --- /dev/null +++ b/source/test/fixtures/files-js/package.json @@ -0,0 +1,8 @@ +{ + "name": "foo", + "tsd": { + "compilerOptions": { + "checkJs": true + } + } +} diff --git a/source/test/fixtures/specify-test-files/syntax.test.ts b/source/test/fixtures/specify-test-files/syntax.test.ts new file mode 100644 index 00000000..f2d9f9d1 --- /dev/null +++ b/source/test/fixtures/specify-test-files/syntax.test.ts @@ -0,0 +1 @@ +const num: number = '1'; diff --git a/source/test/test.ts b/source/test/test.ts index 67bba0ed..9c200bbb 100644 --- a/source/test/test.ts +++ b/source/test/test.ts @@ -1,4 +1,5 @@ -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import test from 'ava'; import {verify, verifyWithFileName} from './fixtures/utils'; import tsd from '..'; @@ -427,3 +428,49 @@ test('parsing undefined symbol should not fail', async t => { verify(t, diagnostics, []); }); + +test('test js files', async t => { + const diagnostics = await tsd({ + cwd: path.join(__dirname, 'fixtures/files-js'), + testFiles: ['index.test-d.js'] + }); + + verify(t, diagnostics, [ + [17, 12, 'error', '\')\' expected.'] + ]); +}); + +test('test string files', async t => { + const diagnostics = await tsd({ + cwd: path.join(__dirname, 'fixtures/specify-test-files'), + testFiles: [ + { + name: 'syntax.test.ts', + text: await fs.promises.readFile(path.join(__dirname, 'fixtures/specify-test-files/syntax.test.ts'), 'utf8'), + }, + ], + }); + + verify(t, diagnostics, [ + [1, 6, 'error', 'Type \'string\' is not assignable to type \'number\'.'] + ]); +}); + +test('test string files with glob files', async t => { + const diagnostics = await tsd({ + cwd: path.join(__dirname, 'fixtures/specify-test-files'), + testFiles: [ + 'unknown.test.ts', + 'second.test.ts', + { + name: 'syntax.test.ts', + text: await fs.promises.readFile(path.join(__dirname, 'fixtures/specify-test-files/syntax.test.ts'), 'utf8'), + }, + ], + }); + + verify(t, diagnostics, [ + [5, 19, 'error', 'Argument of type \'number\' is not assignable to parameter of type \'string\'.'], + [1, 6, 'error', 'Type \'string\' is not assignable to type \'number\'.'] + ]); +});