diff --git a/src/bin.ts b/src/bin.ts index 4a4957d80..71a9c506b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -197,11 +197,6 @@ export function main( let evalStuff: VirtualFileState | undefined; let replStuff: VirtualFileState | undefined; let stdinStuff: VirtualFileState | undefined; - // let evalService: ReplService | undefined; - // let replState: EvalState | undefined; - // let replService: ReplService | undefined; - // let stdinState: EvalState | undefined; - // let stdinService: ReplService | undefined; let evalAwarePartialHost: EvalAwarePartialHost | undefined = undefined; if (executeEval) { const state = new EvalState(join(cwd, EVAL_FILENAME)); @@ -210,6 +205,7 @@ export function main( repl: createRepl({ state, composeWithEvalAwarePartialHost: evalAwarePartialHost, + ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl: false, }), }; ({ evalAwarePartialHost } = evalStuff.repl); @@ -225,6 +221,7 @@ export function main( repl: createRepl({ state, composeWithEvalAwarePartialHost: evalAwarePartialHost, + ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl: false, }), }; ({ evalAwarePartialHost } = stdinStuff.repl); diff --git a/src/index.ts b/src/index.ts index c46446b73..5360a86a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -410,6 +410,8 @@ export interface Service { configFilePath: string | undefined; /** @internal */ moduleTypeClassifier: ModuleTypeClassifier; + /** @internal */ + addDiagnosticFilter(filter: DiagnosticFilter): void; } /** @@ -419,6 +421,16 @@ export interface Service { */ export type Register = Service; +/** @internal */ +export interface DiagnosticFilter { + /** if true, filter applies to all files */ + appliesToAllFiles: boolean; + /** Filter applies onto to these filenames. Only used if appliesToAllFiles is false */ + filenamesAbsolute: string[]; + /** these diagnostic codes are ignored */ + diagnosticsIgnored: number[]; +} + /** @internal */ export function getExtensions(config: _ts.ParsedCommandLine) { const tsExtensions = ['.ts']; @@ -516,16 +528,22 @@ export function create(rawOptions: CreateOptions = {}): Service { const transpileOnly = options.transpileOnly === true && options.typeCheck !== true; const transformers = options.transformers || undefined; - const ignoreDiagnostics = [ - 6059, // "'rootDir' is expected to contain all source files." - 18002, // "The 'files' list in config file is empty." - 18003, // "No inputs were found in config file." - ...(options.ignoreDiagnostics || []), - ].map(Number); + const diagnosticFilters: Array = [ + { + appliesToAllFiles: true, + filenamesAbsolute: [], + diagnosticsIgnored: [ + 6059, // "'rootDir' is expected to contain all source files." + 18002, // "The 'files' list in config file is empty." + 18003, // "No inputs were found in config file." + ...(options.ignoreDiagnostics || []), + ].map(Number), + }, + ]; const configDiagnosticList = filterDiagnostics( config.errors, - ignoreDiagnostics + diagnosticFilters ); const outputCache = new Map< string, @@ -804,7 +822,7 @@ export function create(rawOptions: CreateOptions = {}): Service { const diagnosticList = filterDiagnostics( diagnostics, - ignoreDiagnostics + diagnosticFilters ); if (diagnosticList.length) reportTSError(diagnosticList); @@ -963,7 +981,7 @@ export function create(rawOptions: CreateOptions = {}): Service { const diagnostics = ts.getPreEmitDiagnostics(program, sourceFile); const diagnosticList = filterDiagnostics( diagnostics, - ignoreDiagnostics + diagnosticFilters ); if (diagnosticList.length) reportTSError(diagnosticList); @@ -1079,7 +1097,7 @@ export function create(rawOptions: CreateOptions = {}): Service { const diagnosticList = filterDiagnostics( result.diagnostics || [], - ignoreDiagnostics + diagnosticFilters ); if (diagnosticList.length) reportTSError(diagnosticList); @@ -1141,6 +1159,10 @@ export function create(rawOptions: CreateOptions = {}): Service { return true; }; + function addDiagnosticFilter(filter: DiagnosticFilter) { + diagnosticFilters.push(filter); + } + return { ts, config, @@ -1151,6 +1173,7 @@ export function create(rawOptions: CreateOptions = {}): Service { options, configFilePath, moduleTypeClassifier, + addDiagnosticFilter, }; } @@ -1306,9 +1329,16 @@ function updateSourceMap(sourceMapText: string, fileName: string) { */ function filterDiagnostics( diagnostics: readonly _ts.Diagnostic[], - ignore: number[] + filters: DiagnosticFilter[] ) { - return diagnostics.filter((x) => ignore.indexOf(x.code) === -1); + return diagnostics.filter((d) => + filters.every( + (f) => + (!f.appliesToAllFiles && + f.filenamesAbsolute.indexOf(d.file?.fileName!) === -1) || + f.diagnosticsIgnored.indexOf(d.code) === -1 + ) + ); } /** diff --git a/src/repl.ts b/src/repl.ts index fca8b1bc6..8537e58dd 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -59,6 +59,11 @@ export interface CreateReplOptions { stderr?: NodeJS.WritableStream; /** @internal */ composeWithEvalAwarePartialHost?: EvalAwarePartialHost; + /** + * @internal + * Ignore diagnostics that are annoying when interactively entering input line-by-line. + */ + ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl?: boolean; } /** @@ -86,6 +91,7 @@ export function createRepl(options: CreateReplOptions = {}) { stdout === process.stdout && stderr === process.stderr ? console : new Console(stdout, stderr); + const { ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl = true } = options; const replService: ReplService = { state: options.state ?? new EvalState(join(process.cwd(), EVAL_FILENAME)), @@ -103,6 +109,17 @@ export function createRepl(options: CreateReplOptions = {}) { function setService(_service: Service) { service = _service; + if (ignoreDiagnosticsThatAreAnnoyingInInteractiveRepl) { + service.addDiagnosticFilter({ + appliesToAllFiles: false, + filenamesAbsolute: [state.path], + diagnosticsIgnored: [ + 2393, // Duplicate function implementation: https://github.com/TypeStrong/ts-node/issues/729 + 6133, // is declared but its value is never read. https://github.com/TypeStrong/ts-node/issues/850 + 7027, // Unreachable code detected. https://github.com/TypeStrong/ts-node/issues/469 + ], + }); + } } function evalCode(code: string) { diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index a9d7b91f9..4f4a19c5a 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -33,38 +33,10 @@ import type * as Module from 'module'; import { PassThrough } from 'stream'; import * as getStream from 'get-stream'; import { once } from 'lodash'; +import { createMacrosAndHelpers, ExecMacroAssertionCallback } from './macros'; const xfs = new NodeFS(fs); -type TestExecReturn = { - stdout: string; - stderr: string; - err: null | ExecException; -}; -function exec( - cmd: string, - opts: ExecOptions = {} -): Promise & { child: ChildProcess } { - let childProcess!: ChildProcess; - return Object.assign( - new Promise((resolve, reject) => { - childProcess = childProcessExec( - cmd, - { - cwd: TEST_DIR, - ...opts, - }, - (error, stdout, stderr) => { - resolve({ err: error, stdout, stderr }); - } - ); - }), - { - child: childProcess, - } - ); -} - const ROOT_DIR = resolve(__dirname, '../..'); const DIST_DIR = resolve(__dirname, '..'); const TEST_DIR = join(__dirname, '../../tests'); @@ -81,6 +53,11 @@ const testsDirRequire = createRequire(join(TEST_DIR, 'index.js')); // Set after ts-node is installed locally let { register, create, VERSION, createRepl }: typeof tsNodeTypes = {} as any; +const { exec, createExecMacro } = createMacrosAndHelpers({ + test, + defaultCwd: TEST_DIR, +}); + // Pack and install ts-node locally, necessary to test package "exports" test.beforeAll(async () => { const totalTries = process.platform === 'win32' ? 5 : 1; @@ -101,7 +78,9 @@ test.beforeAll(async () => { }); test.suite('ts-node', (test) => { + /** Default `ts-node --project` invocation */ const cmd = `"${BIN_PATH}" --project "${PROJECT}"`; + /** Default `ts-node` invocation without `--project` */ const cmdNoProject = `"${BIN_PATH}"`; test('should export the correct version', () => { @@ -428,50 +407,52 @@ test.suite('ts-node', (test) => { return { stdin, stdout, stderr, replService, service }; } - // Serial because it's timing-sensitive - test.serial('REPL can be created via API', async () => { - const { stdin, stdout, stderr, replService } = createReplViaApi(); - replService.start(); - stdin.write('\nconst a = 123\n.type a\n'); - stdin.end(); - await promisify(setTimeout)(1e3); - stdout.end(); - stderr.end(); - expect(await getStream(stderr)).to.equal(''); - expect(await getStream(stdout)).to.equal( - '> undefined\n' + '> undefined\n' + '> const a: 123\n' + '> ' - ); + const execMacro = createExecMacro({ + cmd, + cwd: TEST_DIR, }); + type ReplApiMacroAssertions = ( + stdout: string, + stderr: string + ) => Promise; + + const replApiMacro = test.macro( + (opts: { input: string }, assertions: ReplApiMacroAssertions) => async ( + t + ) => { + const { input } = opts; + const { stdin, stdout, stderr, replService } = createReplViaApi(); + replService.start(); + stdin.write(input); + stdin.end(); + await promisify(setTimeout)(1e3); + stdout.end(); + stderr.end(); + const stderrString = await getStream(stderr); + const stdoutString = await getStream(stdout); + await assertions(stdoutString, stderrString); + } + ); + + // Serial because it's timing-sensitive + test.serial( + 'REPL can be created via API', + replApiMacro, + { + input: '\nconst a = 123\n.type a\n', + }, + async (stdout, stderr) => { + expect(stderr).to.equal(''); + expect(stdout).to.equal( + '> undefined\n' + '> undefined\n' + '> const a: 123\n' + '> ' + ); + } + ); + test.suite( '[eval], , and [stdin] execute with correct globals', (test) => { - const cliTest = test.macro( - ( - { - flags, - stdin, - allowError = false, - }: { - flags: string; - stdin: string; - allowError?: boolean; - }, - assertions: ( - stdout: string, - stderr: string, - err: ExecException | null - ) => Promise | void - ) => async (t) => { - const execPromise = exec(`${cmd} ${flags}`); - // Uncomment to run against vanilla node, useful to verify that these test cases match vanilla node - // const execPromise = exec(`node ${flags}`); - execPromise.child.stdin!.end(stdin); - const { err, stdout, stderr } = await execPromise; - if (!allowError) expect(err).to.equal(null); - await assertions(stdout, stderr, err); - } - ); interface GlobalInRepl extends NodeJS.Global { testReport: any; replReport: any; @@ -581,7 +562,7 @@ test.suite('ts-node', (test) => { test( 'stdin', - cliTest, + execMacro, { stdin: `${setReportGlobal('stdin')};${printReports}`, flags: '', @@ -612,7 +593,7 @@ test.suite('ts-node', (test) => { ); test( 'repl', - cliTest, + execMacro, { stdin: `${setReportGlobal('repl')};${printReports}`, flags: '-i', @@ -654,7 +635,7 @@ test.suite('ts-node', (test) => { // Should ignore -i and run the entrypoint test( '-i w/entrypoint ignores -i', - cliTest, + execMacro, { stdin: `${setReportGlobal('repl')};${printReports}`, flags: '-i ./repl/script.js', @@ -673,7 +654,7 @@ test.suite('ts-node', (test) => { // Should not interpret positional arg as an entrypoint script test( '-e', - cliTest, + execMacro, { stdin: `throw new Error()`, flags: `-e "${setReportGlobal('eval')};${printReports}"`, @@ -704,7 +685,7 @@ test.suite('ts-node', (test) => { ); test( '-e w/entrypoint arg does not execute entrypoint', - cliTest, + execMacro, { stdin: `throw new Error()`, flags: `-e "${setReportGlobal( @@ -737,7 +718,7 @@ test.suite('ts-node', (test) => { ); test( '-e w/non-path arg', - cliTest, + execMacro, { stdin: `throw new Error()`, flags: `-e "${setReportGlobal( @@ -770,7 +751,7 @@ test.suite('ts-node', (test) => { ); test( '-e -i', - cliTest, + execMacro, { stdin: `${setReportGlobal('repl')};${printReports}`, flags: `-e "${setReportGlobal('eval')}" -i`, @@ -826,7 +807,7 @@ test.suite('ts-node', (test) => { test( '-e -i w/entrypoint ignores -e and -i, runs entrypoint', - cliTest, + execMacro, { stdin: `throw new Error()`, flags: '-e "throw new Error()" -i ./repl/script.js', @@ -843,11 +824,11 @@ test.suite('ts-node', (test) => { test( '-e -i when -e throws error, -i does not run', - cliTest, + execMacro, { stdin: `console.log('hello')`, flags: `-e "throw new Error('error from -e')" -i`, - allowError: true, + expectError: true, }, (stdout, stderr, err) => { exp(err).toBeDefined(); @@ -943,6 +924,58 @@ test.suite('ts-node', (test) => { } ); + test.suite( + 'REPL ignores diagnostics that are annoying in interactive sessions', + (test) => { + const code = `function foo() {};\nfunction foo() {return 123};\nconsole.log(foo());\n`; + const diagnosticMessage = `Duplicate function implementation`; + test( + 'interactive repl should ignore them', + execMacro, + { + flags: '-i', + stdin: code, + }, + async (stdout, stderr) => { + exp(stdout).not.toContain(diagnosticMessage); + } + ); + test( + 'interactive repl should not ignore them if they occur in other files', + execMacro, + { + flags: '-i', + stdin: `import './repl-ignored-diagnostics/index.ts';\n`, + }, + async (stdout, stderr) => { + exp(stderr).toContain(diagnosticMessage); + } + ); + test( + '[stdin] should not ignore them', + execMacro, + { + stdin: code, + expectError: true, + }, + async (stdout, stderr) => { + exp(stderr).toContain(diagnosticMessage); + } + ); + test( + '[eval] should not ignore them', + execMacro, + { + flags: `-e "${code.replace(/\n/g, '')}"`, + expectError: true, + }, + async (stdout, stderr) => { + exp(stderr).toContain(diagnosticMessage); + } + ); + } + ); + test('should support require flags', async () => { const { err, stdout } = await exec( `${cmd} -r ./hello-world -pe "console.log('success')"` diff --git a/src/test/macros.ts b/src/test/macros.ts new file mode 100644 index 000000000..4e076ae30 --- /dev/null +++ b/src/test/macros.ts @@ -0,0 +1,108 @@ +import type { ChildProcess, ExecException, ExecOptions } from 'child_process'; +import { exec as childProcessExec } from 'child_process'; +import type { TestInterface } from './testlib'; +import { expect } from 'chai'; +import * as exp from 'expect'; + +export type ExecReturn = Promise & { child: ChildProcess }; +export interface ExecResult { + stdout: string; + stderr: string; + err: null | ExecException; + child: ChildProcess; +} + +export interface ExecMacroOptions { + titlePrefix?: string; + cmd: string; + flags?: string; + cwd?: string; + env?: Record; + stdin?: string; + expectError?: boolean; +} +export type ExecMacroAssertionCallback = ( + stdout: string, + stderr: string, + err: ExecException | null +) => Promise | void; + +export interface createMacrosAndHelpersOptions { + test: TestInterface; + defaultCwd: string; +} +export function createMacrosAndHelpers(opts: createMacrosAndHelpersOptions) { + const { test, defaultCwd } = opts; + + /** + * Helper to exec a child process. + * Returns a Promise and a reference to the child process to suite multiple situations. + * Promise resolves with the process's stdout, stderr, and error. + */ + function exec(cmd: string, opts: ExecOptions = {}): ExecReturn { + let child!: ChildProcess; + return Object.assign( + new Promise((resolve, reject) => { + child = childProcessExec( + cmd, + { + cwd: defaultCwd, + ...opts, + }, + (err, stdout, stderr) => { + resolve({ err, stdout, stderr, child }); + } + ); + }), + { + child, + } + ); + } + + /** + * Create a macro that launches a CLI command, optionally pipes stdin, optionally sets env vars, + * and allows assertions against the output. + */ + function createExecMacro>( + preBoundOptions: T + ) { + return test.macro( + ( + options: Pick< + ExecMacroOptions, + Exclude + > & + Partial>, + assertions: ExecMacroAssertionCallback + ) => [ + (title) => `${options.titlePrefix ?? ''}${title}`, + async (t) => { + const { cmd, flags = '', stdin, expectError = false, cwd, env } = { + ...preBoundOptions, + ...options, + }; + const execPromise = exec(`${cmd} ${flags}`, { + cwd, + env: { ...process.env, ...env }, + }); + if (stdin !== undefined) { + execPromise.child.stdin!.end(stdin); + } + const { err, stdout, stderr } = await execPromise; + if (expectError) { + exp(err).toBeDefined(); + } else { + exp(err).toBeNull(); + } + await assertions(stdout, stderr, err); + }, + ] + ); + } + + return { + exec, + createExecMacro, + }; +} diff --git a/src/test/testlib.ts b/src/test/testlib.ts index f3b3a2a15..d48af9ddb 100644 --- a/src/test/testlib.ts +++ b/src/test/testlib.ts @@ -1,3 +1,9 @@ +/* + * Extensions to ava, for declaring and running test cases and suites + * Utilities specific to testing ts-node, for example handling streams and exec-ing processes, + * should go in a separate module. + */ + import avaTest, { ExecutionContext, Implementation, @@ -60,7 +66,7 @@ export interface TestInterface< ...args: Args ) => | [ - (title: string | undefined) => string, + (title: string | undefined) => string | undefined, (t: ExecutionContext) => Promise ] | ((t: ExecutionContext) => Promise) diff --git a/tests/repl-ignored-diagnostics/index.ts b/tests/repl-ignored-diagnostics/index.ts new file mode 100644 index 000000000..77f8fe380 --- /dev/null +++ b/tests/repl-ignored-diagnostics/index.ts @@ -0,0 +1,6 @@ +// This script triggers a diagnostic that is ignored in the virtual file but +// *not* in files such as this one. +// When this file is required by the REPL, the diagnostic *should* be logged. +export {}; +function foo() {} +function foo() {}