From aac45a3d3ce75779bf4951aa7ed566bb27f0ab33 Mon Sep 17 00:00:00 2001 From: Tamara Jordan Date: Thu, 30 Jul 2020 11:39:12 +0100 Subject: [PATCH] feat(ts compilation): improve support for incremental and composite projects If you are building a project that has project references, rather than having to rebuild and typecheck your files, ncdc will make use of the tyepscript build API. It will build any files that have not yet been built and then skip the additional typecheck. fix #337 --- src/commands/generate/command.ts | 9 +- src/commands/serve/index.ts | 10 +- src/commands/test/command.ts | 9 +- src/schema/schema-generator.spec.ts | 68 +------ src/schema/schema-generator.ts | 35 +--- src/schema/ts-helpers.spec.ts | 178 ++++++++++++------- src/schema/ts-helpers.ts | 124 ++++++++++--- src/schema/watching-schema-generator.spec.ts | 17 +- src/schema/watching-schema-generator.ts | 7 +- 9 files changed, 264 insertions(+), 193 deletions(-) diff --git a/src/commands/generate/command.ts b/src/commands/generate/command.ts index a46b4c38..4151352a 100644 --- a/src/commands/generate/command.ts +++ b/src/commands/generate/command.ts @@ -5,6 +5,7 @@ import createHandler, { GenerateArgs } from './handler' import { getConfigTypes } from './config' import { SchemaGenerator } from '~schema' import { generate } from './generate' +import TsHelpers from '~schema/ts-helpers' const builder = (yargs: Argv): Argv => yargs @@ -33,7 +34,13 @@ export default function createGenerateCommand(getCommonDeps: GetRootDeps): Comma generate, getConfigTypes, getSchemaGenerator: (tsconfigPath, force) => { - const generator = new SchemaGenerator(tsconfigPath, force, reportMetric, logger) + const tsHelpers = new TsHelpers(reportMetric, logger) + const generator = new SchemaGenerator( + tsHelpers.createProgram(tsconfigPath, !force), + force, + reportMetric, + logger, + ) generator.init() return generator }, diff --git a/src/commands/serve/index.ts b/src/commands/serve/index.ts index 14187c1d..9dc6e500 100644 --- a/src/commands/serve/index.ts +++ b/src/commands/serve/index.ts @@ -9,6 +9,7 @@ import Ajv from 'ajv' import { FsSchemaLoader, WatchingSchemaGenerator } from '~schema' import { SchemaGenerator } from '~schema' import createServerLogger from './server/server-logger' +import TsHelpers from '~schema/ts-helpers' const builder = (yargs: Argv): Argv => yargs @@ -45,13 +46,20 @@ export default function createServeCommand(getCommonDeps: GetRootDeps) { if (args.schemaPath) return new TypeValidator(ajv, new FsSchemaLoader(args.schemaPath)) if (!args.watch) { - const generator = new SchemaGenerator(args.tsconfigPath, args.force, reportMetric, logger) + const tsHelpers = new TsHelpers(reportMetric, logger) + const generator = new SchemaGenerator( + tsHelpers.createProgram(args.tsconfigPath, !args.force), + args.force, + reportMetric, + logger, + ) generator.init() return new TypeValidator(ajv, generator) } const watcher = new WatchingSchemaGenerator( args.tsconfigPath, + new TsHelpers(reportMetric, logger), logger, reportMetric, onReload, diff --git a/src/commands/test/command.ts b/src/commands/test/command.ts index 33a5393a..0bb089f3 100644 --- a/src/commands/test/command.ts +++ b/src/commands/test/command.ts @@ -9,6 +9,7 @@ import { SchemaGenerator } from '~schema' import Ajv from 'ajv' import { TypeValidator } from '~validation' import { createHttpClient } from './http-client' +import TsHelpers from '~schema/ts-helpers' const builder = (yargs: Argv): Argv => yargs @@ -51,8 +52,14 @@ export default function createTestCommand(getCommonDeps: GetRootDeps): CommandMo createTypeValidator: () => { const ajv = new Ajv({ verbose: true, allErrors: true }) if (schemaPath) return new TypeValidator(ajv, new FsSchemaLoader(schemaPath)) + const tsHelpers = new TsHelpers(reportMetric, logger) - const schemaGenerator = new SchemaGenerator(tsconfigPath, force, reportMetric, logger) + const schemaGenerator = new SchemaGenerator( + tsHelpers.createProgram(tsconfigPath, !force), + force, + reportMetric, + logger, + ) schemaGenerator.init() return new TypeValidator(ajv, schemaGenerator) }, diff --git a/src/schema/schema-generator.spec.ts b/src/schema/schema-generator.spec.ts index 11cbfa4f..ad1d72b8 100644 --- a/src/schema/schema-generator.spec.ts +++ b/src/schema/schema-generator.spec.ts @@ -1,7 +1,6 @@ import { SchemaGenerator } from './schema-generator' import ts from 'typescript' import { mockObj, randomString, mockFn } from '~test-helpers' -import * as tsHelpers from './ts-helpers' import { ReportMetric } from '~commands/shared' import * as tsj from 'ts-json-schema-generator' import { NoRootTypeError } from 'ts-json-schema-generator' @@ -13,13 +12,11 @@ jest.mock('ts-json-schema-generator') jest.mock('typescript') jest.mock('path') jest.mock('fs') -jest.mock('./ts-helpers') describe('SchemaLoader', () => { const mockedTsj = mockObj(tsj) const mockedTsjGenerator = mockObj({ createSchema: jest.fn() }) const mockedTypescript = mockObj(ts) - const mockedTsHelpers = mockObj(tsHelpers) const mockedreportMetric = mockFn() const spyLogger = mockObj({ verbose: jest.fn() }) @@ -35,66 +32,19 @@ describe('SchemaLoader', () => { ) mockedTypescript.getPreEmitDiagnostics.mockReturnValue([]) mockedTsj.SchemaGenerator.mockImplementation(() => mockedTsjGenerator) - mockedTsHelpers.readTsConfig.mockReturnValue({} as ts.ParsedCommandLine) - mockedTsHelpers.formatErrorDiagnostic.mockImplementation(({ messageText }) => - typeof messageText === 'string' ? messageText : 'poop', - ) - mockedreportMetric.mockReturnValue( mockObj({ fail: jest.fn(), success: jest.fn() }), ) }) - const createSchemaGenerator = ( - pathOrProgram: string | ts.Program = '', - skipTypeChecking = false, - ): SchemaGenerator => new SchemaGenerator(pathOrProgram, skipTypeChecking, mockedreportMetric, spyLogger) - - describe('when a tsconfig path is given', () => { - const tsconfigPath = 'tsconfig path' - - it('throws when there are errors and skipTypeChecking is false', () => { - mockedTypescript.getPreEmitDiagnostics.mockReturnValue([ - mockObj({ messageText: 'woah' }), - ]) - - const schemaLoader = createSchemaGenerator(tsconfigPath) - - expect(() => schemaLoader.init()).toThrowError('Your typescript project has compilation errors') - expect(spyLogger.verbose).toBeCalledWith('woah') - }) - - it('does not throw when there are no errors and skipTypeChecking is false', () => { - mockedTypescript.getPreEmitDiagnostics.mockReturnValue([]) - - const schemaLoader = createSchemaGenerator(tsconfigPath) - - expect(() => schemaLoader.init()).not.toThrowError() - }) - - it('does not typecheck if skipTypeChecking is true', () => { - mockedTypescript.getPreEmitDiagnostics.mockReturnValue([ - mockObj({ messageText: 'woah' }), - ]) - - const schemaLoader = createSchemaGenerator(tsconfigPath, true) - - expect(() => schemaLoader.init()).not.toThrowError() - expect(mockedTypescript.getPreEmitDiagnostics).not.toBeCalled() - }) - }) - - it('calls read ts config with the correct args', () => { - const tsconfigPath = randomString('tsconfig path') - - createSchemaGenerator(tsconfigPath).init() - - expect(mockedTsHelpers.readTsConfig).toBeCalledWith(tsconfigPath) - }) + const createSchemaGenerator = (skipTypeChecking = false): SchemaGenerator => { + const mockProgram = mockObj({}) + return new SchemaGenerator(mockProgram, skipTypeChecking, mockedreportMetric, spyLogger) + } describe('loading schemas', () => { it('throws if there is no generator', async () => { - const schemaGenerator = createSchemaGenerator('tsconfig path', true) + const schemaGenerator = createSchemaGenerator(true) await expect(() => schemaGenerator.load('bananas')).rejects.toThrowError( 'This SchemaGenerator instance has not been initialised', @@ -106,7 +56,7 @@ describe('SchemaLoader', () => { throw new NoRootTypeError(randomString('yikes')) }) - const schemaGenerator = createSchemaGenerator('', true) + const schemaGenerator = createSchemaGenerator(true) schemaGenerator.init() await expect(schemaGenerator.load('lol')).rejects.toThrowError('Could not find type: lol') @@ -117,7 +67,7 @@ describe('SchemaLoader', () => { throw new Error(randomString('yikes')) }) - const schemaGenerator = createSchemaGenerator('', true) + const schemaGenerator = createSchemaGenerator(true) schemaGenerator.init() await expect(schemaGenerator.load('lol')).rejects.toThrowError( @@ -129,7 +79,7 @@ describe('SchemaLoader', () => { const someSchema = { $schema: 'schema stuff' } mockedTsjGenerator.createSchema.mockReturnValue(someSchema) - const schemaLoader = createSchemaGenerator('tsconfig path', true) + const schemaLoader = createSchemaGenerator(true) schemaLoader.init() const schema = await schemaLoader.load('DealSchema') @@ -141,7 +91,7 @@ describe('SchemaLoader', () => { const someSchema2 = { $schema: 'schema stuff 2' } mockedTsjGenerator.createSchema.mockReturnValueOnce(someSchema).mockReturnValueOnce(someSchema2) - const schemaLoader = createSchemaGenerator('tsconfig path', true) + const schemaLoader = createSchemaGenerator(true) schemaLoader.init() const schema1 = await schemaLoader.load('DealSchema') const schema2 = await schemaLoader.load('DealSchema') diff --git a/src/schema/schema-generator.ts b/src/schema/schema-generator.ts index 8d09622a..b83aa9db 100644 --- a/src/schema/schema-generator.ts +++ b/src/schema/schema-generator.ts @@ -1,6 +1,5 @@ import { SchemaRetriever } from './types' -import ts from 'typescript' -import { readTsConfig, formatErrorDiagnostic } from './ts-helpers' +import type { Program } from 'typescript' import { ReportMetric } from '~commands/shared' import { SchemaGenerator as TsSchemaGenerator, @@ -19,15 +18,14 @@ export class SchemaGenerator implements SchemaRetriever { private generateJsonSchema?: JsonSchemaGenerator constructor( - private readonly pathOrProgram: string | ts.Program, + private readonly program: Program, private readonly skipTypeChecking: boolean, private readonly reportMetric: ReportMetric, private readonly logger: NcdcLogger, ) {} public init(): void { - const program = this.getTsProgram() - this.generateJsonSchema = this.createGenerator(program) + this.generateJsonSchema = this.createGenerator(this.program) } public load = async (symbolName: string): Promise => { @@ -58,32 +56,7 @@ export class SchemaGenerator implements SchemaRetriever { } } - private getTsProgram(): ts.Program { - if (typeof this.pathOrProgram !== 'string') return this.pathOrProgram - const { success, fail } = this.reportMetric('build a typescript program') - const configFile = readTsConfig(this.pathOrProgram) - - const incrementalProgram = ts.createIncrementalProgram({ - rootNames: configFile.fileNames, - options: configFile.options, - projectReferences: configFile.projectReferences, - }) - const program = incrementalProgram.getProgram() - - if (!this.skipTypeChecking) { - const diagnostics = ts.getPreEmitDiagnostics(program) - if (diagnostics.length) { - fail() - this.logger.verbose(diagnostics.map(formatErrorDiagnostic).join('\n')) - throw new Error('Your typescript project has compilation errors. Run tsc to debug.') - } - } - - success() - return program - } - - private createGenerator(program: ts.Program): JsonSchemaGenerator { + private createGenerator(program: Program): JsonSchemaGenerator { const { success } = this.reportMetric('build a schema generator') const config: Config = { skipTypeCheck: true, expose: 'all', additionalProperties: true } const generator = new TsSchemaGenerator( diff --git a/src/schema/ts-helpers.spec.ts b/src/schema/ts-helpers.spec.ts index a743d995..f96ae905 100644 --- a/src/schema/ts-helpers.spec.ts +++ b/src/schema/ts-helpers.spec.ts @@ -1,102 +1,144 @@ -import { formatErrorDiagnostic, readTsConfig } from './ts-helpers' -import { mockObj, randomString } from '~test-helpers' +import { mockObj, mockFn, randomString } from '~test-helpers' import ts from 'typescript' -import path from 'path' +import TsHelpers from './ts-helpers' +import { ReportMetric } from '~commands/shared' +import { NcdcLogger } from '~logger' jest.disableAutomock() jest.mock('typescript') jest.mock('path') -const mockedTs = mockObj(ts) -const mockedPath = mockObj(path) +describe('create program', () => { + const spyLogger = mockObj({ verbose: jest.fn() }) + const spyReportMetric = mockFn() + const helpers = new TsHelpers(spyReportMetric, spyLogger) -beforeEach(() => { - jest.resetAllMocks() -}) + const tsconfigPath = randomString('tsconfig.json') + const mockTs = mockObj(ts) -describe('format error diagnostic', () => { - it('returns a formatted error', () => { - const diagnostic = mockObj({ code: 321, messageText: 'message' }) - mockedTs.flattenDiagnosticMessageText.mockReturnValue('formatted message') + beforeEach(() => { + jest.resetAllMocks() + spyReportMetric.mockReturnValue({ fail: jest.fn(), subMetric: jest.fn(), success: jest.fn() }) + mockTs.readConfigFile.mockReturnValue({ config: {} }) + mockTs.parseJsonConfigFileContent.mockReturnValue({ + errors: [], + options: {}, + fileNames: [], + }) + }) - const result = formatErrorDiagnostic(diagnostic) + it('creates a regular ts program when incremental and composite are not set', () => { + const expectedProgram = mockObj({ emit: jest.fn() }) + mockTs.createProgram.mockReturnValueOnce(expectedProgram) - expect(mockedTs.flattenDiagnosticMessageText).toBeCalledWith(diagnostic.messageText, ts.sys.newLine) - expect(result).toEqual('Error 321: formatted message') - }) -}) + const result = helpers.createProgram(tsconfigPath, false) -describe('read ts config', () => { - beforeEach(() => { - mockedTs.readConfigFile.mockReturnValue({ config: {} }) - mockedTs.parseJsonConfigFileContent.mockReturnValue({ options: {}, errors: [], fileNames: [] }) + expect(result).toStrictEqual(expectedProgram) }) - const tsconfigPath = './tsconfig.json' - it('it calls read config file with the correct args', () => { - const fullTsPath = randomString() + 'tsconfig.json' - mockedPath.resolve.mockReturnValue(fullTsPath) + it('creates an incremental program when incremental is set', () => { + mockTs.parseJsonConfigFileContent.mockReturnValueOnce({ + errors: [], + options: { incremental: true }, + fileNames: [], + }) + const expectedProgram = mockObj({ emit: jest.fn() }) + mockTs.createIncrementalProgram.mockReturnValueOnce( + mockObj({ getProgram: jest.fn().mockReturnValue(expectedProgram) }), + ) - readTsConfig(tsconfigPath) + const result = helpers.createProgram(tsconfigPath, false) - expect(mockedPath.resolve).toBeCalledWith(tsconfigPath) - expect(mockedTs.readConfigFile).toBeCalledWith(fullTsPath, mockedTs.sys.readFile) + expect(result).toStrictEqual(expectedProgram) }) - it('throws if there is a config file error', () => { - mockedTs.readConfigFile.mockReturnValue({ error: mockObj({ code: 123 }) }) + it('ignores pre emit diagnostics when shouldTypecheck is false', () => { + helpers.createProgram(tsconfigPath, false) - expect(() => readTsConfig(tsconfigPath)).toThrowError('Error 123:') + expect(mockTs.getPreEmitDiagnostics).not.toBeCalled() }) - it('throws if no config file is returned', () => { - mockedTs.readConfigFile.mockReturnValue({}) + it('fails on pre emit diagnostics when shouldTypecheck is true and there were errors', () => { + mockTs.getPreEmitDiagnostics.mockReturnValueOnce([mockObj({ code: 567 })]) - expect(() => readTsConfig(tsconfigPath)).toThrowError('Could not parse the given tsconfig file') + expect(() => helpers.createProgram(tsconfigPath, true)).toThrowError('project has compilation errors') + expect(spyLogger.verbose).toBeCalledWith(expect.stringContaining('Error 567')) }) - it('parses the json using the correct args', () => { - const returnedConfig = mockObj({ options: {} }) - mockedTs.readConfigFile.mockReturnValue({ config: returnedConfig }) - const tsconfigFolderName = randomString('folder name') - mockedPath.dirname.mockReturnValue(tsconfigFolderName) - const fullTsconfigPath = randomString('full tsconfig path') - mockedPath.resolve.mockReturnValue(fullTsconfigPath) - - readTsConfig(tsconfigPath) - - expect(mockedTs.parseJsonConfigFileContent).toBeCalledWith( - returnedConfig, - mockedTs.sys, - tsconfigFolderName, - {}, - fullTsconfigPath, + it('throws an error when there is an error reading a raw config file', () => { + mockTs.readConfigFile.mockReturnValueOnce({ error: mockObj({ code: 123 }) }) + + expect(() => helpers.createProgram(tsconfigPath, false)).toThrowError('Error 123:') + }) + + it('throws an error when there is apparently no config', () => { + mockTs.readConfigFile.mockReturnValueOnce({}) + + expect(() => helpers.createProgram(tsconfigPath, false)).toThrowError( + 'Could not parse your tsconfig file', ) }) - it.each([ - [true, false], - [false, true], - ])('returns the correct config options when incremental is %s', (incremental, noEmit) => { - mockedTs.readConfigFile.mockReturnValue({ config: {} }) - const configFile = mockObj({ options: { incremental }, fileNames: ['toad'] }) - mockedTs.parseJsonConfigFileContent.mockReturnValue(configFile) + it('throws an error when parsing the content of tsconfig gives errors', () => { + mockTs.parseJsonConfigFileContent.mockReturnValueOnce({ + errors: [mockObj({ code: 321 })], + options: {}, + fileNames: [], + }) - const result = readTsConfig(tsconfigPath) + expect(() => helpers.createProgram(tsconfigPath, false)).toThrowError('Error 321:') + }) + + it('throws an error when building a program fails', () => { + mockTs.createProgram.mockImplementation(() => { + throw new Error('Oh no') + }) - expect(result).toEqual({ fileNames: ['toad'], options: { ...configFile.options, noEmit } }) + expect(() => helpers.createProgram(tsconfigPath, false)).toThrowError('project has compilation errors') }) - it.each([ - [true, false], - [false, true], - ])('returns the correct config options when composite flag is %s', (composite, noEmit) => { - mockedTs.readConfigFile.mockReturnValue({ config: {} }) - const configFile = mockObj({ options: { composite }, fileNames: ['toad'] }) - mockedTs.parseJsonConfigFileContent.mockReturnValue(configFile) + describe('when composite is true', () => { + const expectedProgram = mockObj({ emit: jest.fn() }) + const builder = mockObj>({ + build: jest.fn(), + }) + + beforeEach(() => { + mockTs.createIncrementalProgram.mockReturnValueOnce( + mockObj({ getProgram: jest.fn().mockReturnValueOnce(expectedProgram) }), + ) + mockTs.parseJsonConfigFileContent.mockReturnValueOnce({ + errors: [], + options: { composite: true }, + fileNames: [], + }) + + builder.build.mockReturnValue(ts.ExitStatus.Success) + mockTs.createSolutionBuilder.mockReturnValueOnce(builder) + }) + + it('builds a solution', () => { + helpers.createProgram(tsconfigPath, false) + + expect(builder.build).toBeCalled() + }) + + it('throws an error if the solution could not be built', () => { + builder.build.mockReturnValue(ts.ExitStatus.DiagnosticsPresent_OutputsSkipped) + + expect(() => helpers.createProgram(tsconfigPath, false)).toThrowError('project has compilation errors') + }) + + it('returns an incremental program', () => { + const result = helpers.createProgram(tsconfigPath, false) + + expect(result).toStrictEqual(expectedProgram) + }) - const result = readTsConfig(tsconfigPath) + it('does not do a manual typecheck even when typechecking is enabled', () => { + helpers.createProgram(tsconfigPath, true) - expect(result).toEqual({ fileNames: ['toad'], options: { ...configFile.options, noEmit } }) + expect(ts.getPreEmitDiagnostics).not.toBeCalled() + }) }) }) diff --git a/src/schema/ts-helpers.ts b/src/schema/ts-helpers.ts index b1eebdaa..aaa2fdc2 100644 --- a/src/schema/ts-helpers.ts +++ b/src/schema/ts-helpers.ts @@ -1,26 +1,106 @@ +import { ReportMetric } from '~commands/shared' +import { NcdcLogger } from '~logger' import ts from 'typescript' -import { resolve, dirname } from 'path' +import { dirname, resolve } from 'path' -export function formatErrorDiagnostic(diagnostic: ts.Diagnostic): string { - return `Error ${diagnostic.code}: ${ts.flattenDiagnosticMessageText( - diagnostic.messageText, - ts.sys.newLine, - )}` -} +export default class TsHelpers { + private readonly compilationErrorMessage = + 'Your typescript project has compilation errors. Run tsc to debug.' + + constructor(private readonly reportMetric: ReportMetric, private readonly logger: NcdcLogger) {} + + public createProgram(tsconfigPath: string, shouldTypecheck: boolean): ts.Program { + const config = this.readTsConfig(tsconfigPath) + if (config.options.composite) this.buildSolution(tsconfigPath) + const program = this.buildProgram(config) + + // composite programs already get type checked during the solution build + if (shouldTypecheck && !config.options.composite) this.typecheck(program) + return program + } + + public typecheck(program: ts.Program): void { + const { success, fail } = this.reportMetric('typecheck') + const diagnostics = ts.getPreEmitDiagnostics(program) + + if (diagnostics.length) { + fail() + diagnostics.forEach((d) => this.logger.verbose(this.formatErrorDiagnostic(d))) + throw new Error(this.compilationErrorMessage) + } + + success() + } + + public formatErrorDiagnostic = (diagnostic: ts.Diagnostic): string => { + return `Error ${diagnostic.code}: ${ts.flattenDiagnosticMessageText( + diagnostic.messageText, + ts.sys.newLine, + )}` + } + + public readTsConfig(path: string): ts.ParsedCommandLine { + const tsconfigPath = resolve(path) + const rawConfigFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile) + if (rawConfigFile.error) throw new Error(this.formatErrorDiagnostic(rawConfigFile.error)) + if (!rawConfigFile.config) throw new Error('Could not parse your tsconfig file') + + const configFile = ts.parseJsonConfigFileContent( + rawConfigFile.config, + ts.sys, + dirname(tsconfigPath), + {}, + tsconfigPath, + ) + if (configFile.errors.length) { + throw new Error(configFile.errors.map(this.formatErrorDiagnostic).join('\n')) + } + + const shouldEmit = !!configFile.options.incremental || !!configFile.options.composite + const fallbackTsbuild = configFile.options.outDir && `${configFile.options.outDir}/tsconfig.tsbuildinfo` + + configFile.options.incremental = shouldEmit + configFile.options.tsBuildInfoFile = configFile.options.tsBuildInfoFile || fallbackTsbuild + configFile.options.noEmit = shouldEmit ? undefined : true + return configFile + } + + private buildSolution(tsconfigPath: string): void { + const { success, fail } = this.reportMetric('build typescript solution') + const solutionBuilderHost = ts.createSolutionBuilderHost(ts.sys, undefined, (dianostic) => + this.logger.verbose(this.formatErrorDiagnostic(dianostic)), + ) + const solutionBuilder = ts.createSolutionBuilder(solutionBuilderHost, [tsconfigPath], {}) + const exitCode = solutionBuilder.build() + + if (exitCode !== ts.ExitStatus.Success) { + fail() + throw new Error(this.compilationErrorMessage) + } + + success() + } + + private buildProgram(config: ts.ParsedCommandLine): ts.Program { + const { success, fail } = this.reportMetric('build typescript program') + + const programArgs = { + options: config.options, + rootNames: config.fileNames, + projectReferences: config.projectReferences, + configFileParsingDiagnostics: ts.getConfigFileParsingDiagnostics(config), + } -export function readTsConfig(path: string): ts.ParsedCommandLine { - const tsconfigPath = resolve(path) - const rawConfigFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile) - if (rawConfigFile.error) throw new Error(formatErrorDiagnostic(rawConfigFile.error)) - if (!rawConfigFile.config) throw new Error('Could not parse the given tsconfig file') - - const configFile = ts.parseJsonConfigFileContent( - rawConfigFile.config, - ts.sys, - dirname(tsconfigPath), - {}, - tsconfigPath, - ) - configFile.options.noEmit = !(configFile.options.incremental || configFile.options.composite) - return configFile + try { + const program = config.options.incremental + ? ts.createIncrementalProgram(programArgs).getProgram() + : ts.createProgram(programArgs) + success() + return program + } catch (err) { + fail() + this.logger.verbose(err.message) + throw new Error(this.compilationErrorMessage) + } + } } diff --git a/src/schema/watching-schema-generator.spec.ts b/src/schema/watching-schema-generator.spec.ts index 2ca32a68..76b5ebb9 100644 --- a/src/schema/watching-schema-generator.spec.ts +++ b/src/schema/watching-schema-generator.spec.ts @@ -1,18 +1,17 @@ import { WatchingSchemaGenerator } from './watching-schema-generator' import { randomString, mockObj, mockFn } from '~test-helpers' import ts from 'typescript' -import * as tsHelpers from './ts-helpers' import { NcdcLogger } from '~logger' import { ReportMetric } from '~commands/shared' +import TsHelpers from './ts-helpers' jest.disableAutomock() jest.mock('typescript-json-schema') jest.mock('typescript') -jest.mock('./ts-helpers') describe('load', () => { const mockTypescript = mockObj(ts) - const mockTsHelpers = mockObj(tsHelpers) + const mockTsHelpers = mockObj({ formatErrorDiagnostic: jest.fn(), readTsConfig: jest.fn() }) const mockLogger = mockObj({}) const mockreportMetric = mockFn() @@ -29,8 +28,12 @@ describe('load', () => { mockreportMetric.mockReturnValue({ success: jest.fn(), fail: jest.fn(), subMetric: jest.fn() }) }) + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const createGenerator = () => + new WatchingSchemaGenerator(randomString('tsconfig.json'), mockTsHelpers, mockLogger, mockreportMetric) + it('throws an error if watching has not started yet', () => { - const generator = new WatchingSchemaGenerator(randomString('tsconfig path'), mockLogger, mockreportMetric) + const generator = createGenerator() expect(() => generator.load(randomString('my type'))).toThrowError('Watching has not started yet') }) @@ -41,13 +44,13 @@ describe('load', () => { throw expectedError }) - const generator = new WatchingSchemaGenerator(randomString('tsconfig path'), mockLogger, mockreportMetric) + const generator = createGenerator() expect(() => generator.init()).toThrow(expectedError) }) it('does not try to read the config file again if it is already initialised', () => { - const generator = new WatchingSchemaGenerator(randomString('tsconfig path'), mockLogger, mockreportMetric) + const generator = createGenerator() generator.init() generator.init() @@ -64,7 +67,7 @@ describe('load', () => { mockObj({ options: { noEmit } }), ) - new WatchingSchemaGenerator(randomString('tsconfig path'), mockLogger, mockreportMetric).init() + createGenerator().init() expect(mockTypescript.createWatchCompilerHost.mock.calls[0][1]).toMatchObject({ noEmit, diff --git a/src/schema/watching-schema-generator.ts b/src/schema/watching-schema-generator.ts index 06a033e6..093d4a0d 100644 --- a/src/schema/watching-schema-generator.ts +++ b/src/schema/watching-schema-generator.ts @@ -3,9 +3,9 @@ import { resolve } from 'path' import { SchemaRetriever } from './types' import ts from 'typescript' import type { Definition } from 'ts-json-schema-generator' -import { formatErrorDiagnostic, readTsConfig } from './ts-helpers' import { NcdcLogger } from '~logger' import { ReportMetric } from '~commands/shared' +import TsHelpers from './ts-helpers' export type CompilerHook = () => Promise | void @@ -23,6 +23,7 @@ export class WatchingSchemaGenerator implements SchemaRetriever { public constructor( tsconfigPath: string, + private readonly tsHelpers: TsHelpers, private readonly logger: NcdcLogger, private readonly reportMetric: ReportMetric, private readonly onReload?: CompilerHook, @@ -36,7 +37,7 @@ export class WatchingSchemaGenerator implements SchemaRetriever { const { success } = this.reportMetric('Initiating typescript watcher') this.initiated = true - const configFile = readTsConfig(this.tsconfigPath) + const configFile = this.tsHelpers.readTsConfig(this.tsconfigPath) const watcherHost = ts.createWatchCompilerHost( this.tsconfigPath, { noEmit: configFile.options.noEmit }, @@ -74,7 +75,7 @@ export class WatchingSchemaGenerator implements SchemaRetriever { } private reportDiagnostic: ts.DiagnosticReporter = (diagnostic) => - this.logger.verbose(formatErrorDiagnostic(diagnostic)) + this.logger.verbose(this.tsHelpers.formatErrorDiagnostic(diagnostic)) private reportWatchStatus: ts.WatchStatusReporter = (diagnostic, _1, _2, errorCount): void => { this.programHasErrors = false