diff --git a/__tests__/integration/errors.spec.ts b/__tests__/integration/errors.spec.ts index d44e09e8..0c6b6387 100644 --- a/__tests__/integration/errors.spec.ts +++ b/__tests__/integration/errors.spec.ts @@ -1,4 +1,5 @@ import { jest, afterAll, test, expect } from "@jest/globals"; +import { Mock } from "jest-mock" import * as path from "path"; import { normalizePath as normalize } from "@rollup/pluginutils"; import * as fs from "fs-extra"; @@ -11,7 +12,6 @@ jest.setTimeout(15000); const local = (x: string) => path.resolve(__dirname, x); const cacheRoot = local("__temp/errors/rpt2-cache"); // don't use the one in node_modules -const onwarn = jest.fn(); afterAll(async () => { // workaround: there seems to be some race condition causing fs.remove to fail, so give it a sec first (c.f. https://github.com/jprichardson/node-fs-extra/issues/532) @@ -19,7 +19,7 @@ afterAll(async () => { await fs.remove(cacheRoot); }); -async function genBundle(relInput: string, extraOpts?: RPT2Options) { +async function genBundle(relInput: string, extraOpts?: RPT2Options, onwarn?: Mock) { const input = normalize(local(`fixtures/errors/${relInput}`)); return helpers.genBundle({ input, @@ -42,9 +42,10 @@ test("integration - semantic error", async () => { }); test("integration - semantic error - abortOnError: false / check: false", async () => { + const onwarn = jest.fn(); // either warning or not type-checking should result in the same bundle - const { output } = await genBundle("semantic.ts", { abortOnError: false }); - const { output: output2 } = await genBundle("semantic.ts", { check: false }); + const { output } = await genBundle("semantic.ts", { abortOnError: false }, onwarn); + const { output: output2 } = await genBundle("semantic.ts", { check: false }, onwarn); expect(output).toEqual(output2); expect(output[0].fileName).toEqual("index.js"); @@ -59,7 +60,38 @@ test("integration - syntax error", () => { }); test("integration - syntax error - abortOnError: false / check: false", () => { + const onwarn = jest.fn(); const err = "Unexpected token (Note that you need plugins to import files that are not JavaScript)"; - expect(genBundle("syntax.ts", { abortOnError: false })).rejects.toThrow(err); - expect(genBundle("syntax.ts", { check: false })).rejects.toThrow(err); + expect(genBundle("syntax.ts", { abortOnError: false }, onwarn)).rejects.toThrow(err); + expect(genBundle("syntax.ts", { check: false }, onwarn)).rejects.toThrow(err); +}); + +const typeOnlyIncludes = ["**/import-type-error.ts", "**/type-only-import-with-error.ts"]; + +test("integration - type-only import error", () => { + expect(genBundle("import-type-error.ts", { + include: typeOnlyIncludes, + })).rejects.toThrow("Property 'nonexistent' does not exist on type 'someObj'."); +}); + +test("integration - type-only import error - abortOnError: false / check: false", async () => { + const onwarn = jest.fn(); + // either warning or not type-checking should result in the same bundle + const { output } = await genBundle("import-type-error.ts", { + include: typeOnlyIncludes, + abortOnError: false, + }, onwarn); + const { output: output2 } = await genBundle("import-type-error.ts", { + include: typeOnlyIncludes, + check: false, + }, onwarn); + expect(output).toEqual(output2); + + expect(output[0].fileName).toEqual("index.js"); + expect(output[1].fileName).toEqual("import-type-error.d.ts"); + expect(output[2].fileName).toEqual("import-type-error.d.ts.map"); + expect(output[3].fileName).toEqual("type-only-import-with-error.d.ts"); + expect(output[4].fileName).toEqual("type-only-import-with-error.d.ts.map"); + expect(output.length).toEqual(5); // no other files + expect(onwarn).toBeCalledTimes(1); }); diff --git a/__tests__/integration/fixtures/errors/import-type-error.ts b/__tests__/integration/fixtures/errors/import-type-error.ts new file mode 100644 index 00000000..d6f46a1b --- /dev/null +++ b/__tests__/integration/fixtures/errors/import-type-error.ts @@ -0,0 +1,8 @@ +// this file has no errors itself; it is used an entry file to test an error in a type-only import + +export type { typeError } from "./type-only-import-with-error"; + +// some code so this file isn't empty +export function sum(a: number, b: number) { + return a + b; +} diff --git a/__tests__/integration/fixtures/errors/type-only-import-with-error.ts b/__tests__/integration/fixtures/errors/type-only-import-with-error.ts new file mode 100644 index 00000000..239cdadf --- /dev/null +++ b/__tests__/integration/fixtures/errors/type-only-import-with-error.ts @@ -0,0 +1,2 @@ +type someObj = {}; +export type typeError = someObj['nonexistent']; diff --git a/src/index.ts b/src/index.ts index 367e02fe..03bc7130 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { relative, dirname, normalize as pathNormalize, resolve } from "path"; import * as tsTypes from "typescript"; -import { PluginImpl, PluginContext, InputOptions, OutputOptions, TransformResult, SourceMap, Plugin } from "rollup"; +import { PluginImpl, InputOptions, TransformResult, SourceMap, Plugin } from "rollup"; import { normalizePath as normalize } from "@rollup/pluginutils"; import { blue, red, yellow, green } from "colors/safe"; import findCacheDir from "find-cache-dir"; @@ -33,6 +33,7 @@ const typescript: PluginImpl = (options) => let service: tsTypes.LanguageService; let noErrors = true; const declarations: { [name: string]: { type: tsTypes.OutputFile; map?: tsTypes.OutputFile } } = {}; + const checkedFiles = new Set(); let _cache: TsCache; const cache = (): TsCache => @@ -55,6 +56,8 @@ const typescript: PluginImpl = (options) => const typecheckFile = (id: string, snapshot: tsTypes.IScriptSnapshot, tcContext: IContext) => { + checkedFiles.add(id); // must come before print, as that could bail + const diagnostics = getDiagnostics(id, snapshot); printDiagnostics(tcContext, diagnostics, parsedConfig.options.pretty !== false); @@ -62,6 +65,15 @@ const typescript: PluginImpl = (options) => noErrors = false; } + /** to be called at the end of Rollup's build phase, before output generation */ + const buildDone = (): void => + { + if (!watchMode && !noErrors) + context.info(yellow("there were errors or warnings.")); + + cache().done(); + } + const pluginOptions: IOptions = Object.assign({}, { check: true, @@ -86,7 +98,7 @@ const typescript: PluginImpl = (options) => } setTypescriptModule(pluginOptions.typescript); - const self: Plugin & { _ongenerate: () => void, _onwrite: (this: PluginContext, _output: OutputOptions) => void } = { + const self: Plugin = { name: "rpt2", @@ -141,6 +153,7 @@ const typescript: PluginImpl = (options) => { const key = normalize(id); delete declarations[key]; + checkedFiles.delete(key); }, resolveId(importee, importer) @@ -201,9 +214,7 @@ const typescript: PluginImpl = (options) => noErrors = false; // always checking on fatal errors, even if options.check is set to false typecheckFile(id, snapshot, contextWrapper); - // since no output was generated, aborting compilation - cache().done(); this.error(red(`failed to transpile '${id}'`)); } @@ -254,28 +265,22 @@ const typescript: PluginImpl = (options) => buildEnd(err) { - if (!err) - return - - // workaround: err.stack contains err.message and Rollup prints both, causing duplication, so split out the stack itself if it exists (c.f. https://github.com/ezolenko/rollup-plugin-typescript2/issues/103#issuecomment-1172820658) - const stackOnly = err.stack?.split(err.message)[1]; - if (stackOnly) - this.error({ ...err, message: err.message, stack: stackOnly }); - else - this.error(err); - }, - - generateBundle(bundleOptions) - { - self._ongenerate(); - self._onwrite.call(this, bundleOptions); - }, + if (err) + { + buildDone(); + // workaround: err.stack contains err.message and Rollup prints both, causing duplication, so split out the stack itself if it exists (c.f. https://github.com/ezolenko/rollup-plugin-typescript2/issues/103#issuecomment-1172820658) + const stackOnly = err.stack?.split(err.message)[1]; + if (stackOnly) + this.error({ ...err, message: err.message, stack: stackOnly }); + else + this.error(err); + } - _ongenerate(): void - { - context.debug(() => `generating target ${generateRound + 1}`); + if (!pluginOptions.check) + return buildDone(); - if (pluginOptions.check && watchMode && generateRound === 0) + // walkTree once on each cycle when in watch mode + if (watchMode) { cache().walkTree((id) => { @@ -288,15 +293,30 @@ const typescript: PluginImpl = (options) => }); } - if (!watchMode && !noErrors) - context.info(yellow("there were errors or warnings.")); + const contextWrapper = new RollupContext(pluginOptions.verbosity, pluginOptions.abortOnError, this, "rpt2: "); - cache().done(); - generateRound++; + // type-check missed files as well + parsedConfig.fileNames.forEach((name) => + { + const key = normalize(name); + if (checkedFiles.has(key) || !filter(key)) // don't duplicate if it's already been checked + return; + + context.debug(() => `type-checking missed '${key}'`); + + const snapshot = servicesHost.getScriptSnapshot(key); + if (snapshot) + typecheckFile(key, snapshot, contextWrapper); + }); + + buildDone(); }, - _onwrite(this: PluginContext, _output: OutputOptions): void + generateBundle(this, _output) { + context.debug(() => `generating target ${generateRound + 1}`); + generateRound++; + if (!parsedConfig.options.declaration) return;