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

Allow testing string sources #181

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 5 additions & 6 deletions source/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
Expand Down
32 changes: 27 additions & 5 deletions source/lib/compiler.ts
Original file line number Diff line number Diff line change
@@ -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<number>([
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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),
];
};
24 changes: 15 additions & 9 deletions source/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => {
Expand Down Expand Up @@ -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}) => {
Expand All @@ -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))};
};

/**
Expand Down
14 changes: 13 additions & 1 deletion source/lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
22 changes: 22 additions & 0 deletions source/lib/utils/filter-test-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {TestFiles} from '../interfaces';

// https://regex101.com/r/8JO8Wb/1
const regex = /^(?<name>[^\s:]+\.[^\s:]+):(?<text>.*)$/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}>;
};
45 changes: 44 additions & 1 deletion source/lib/utils/typescript.ts
Original file line number Diff line number Diff line change
@@ -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 = <R extends 'JSDoc' | 'DocComment'>(resolve: R) => {
type ConditionalResolveReturn = (R extends 'JSDoc' ? Map<string, JSDocTagInfo> : string) | undefined;
Expand Down Expand Up @@ -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);
};
68 changes: 56 additions & 12 deletions source/test/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 => {
Expand All @@ -31,7 +32,7 @@ test('provide a path', async t => {
const {exitCode, stderr} = await t.throwsAsync<ExecaError>(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 => {
Expand All @@ -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<ExecaError>(execa('../../../cli.js', [arg, 'utils/index.d.ts'], {
cwd: path.join(__dirname, 'fixtures/typings-custom-dir')
}));
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.'));
Expand All @@ -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<ExecaError>(execa('../../../cli.js', [arg, 'unknown.test.ts'], {
cwd: path.join(__dirname, 'fixtures/specify-test-files')
}));
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.'));
Expand All @@ -78,9 +83,11 @@ test('cli files flag', async t => {
});

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')
}));
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.'));
Expand All @@ -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<ExecaError>(
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<ExecaError>(
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);
});
9 changes: 9 additions & 0 deletions source/test/fixtures/files-js/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type Foo = {
a: number;
b: string;
};

export type Bar = {
a: string;
b: number;
};
17 changes: 17 additions & 0 deletions source/test/fixtures/files-js/index.test-d.js
Original file line number Diff line number Diff line change
@@ -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(;
8 changes: 8 additions & 0 deletions source/test/fixtures/files-js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "foo",
"tsd": {
"compilerOptions": {
"checkJs": true
}
}
}
1 change: 1 addition & 0 deletions source/test/fixtures/specify-test-files/syntax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const num: number = '1';