diff --git a/.vscode/launch.json b/.vscode/launch.json index 867396e489..53d2180992 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,15 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "program": "${file}", + "skipFiles": [ + "/**" + ] + }, { "type": "node", "request": "launch", @@ -60,12 +69,6 @@ ], "internalConsoleOptions": "openOnSessionStart" }, - { - "type": "node", - "request": "launch", - "name": "Launch Program", - "program": "${workspaceRoot}\\packages\\stryker-mocha-runner\"" - }, { "type": "node", "request": "attach", diff --git a/packages/api/src/check/MutantStatus.ts b/packages/api/src/check/MutantStatus.ts new file mode 100644 index 0000000000..fe94b88bb2 --- /dev/null +++ b/packages/api/src/check/MutantStatus.ts @@ -0,0 +1,10 @@ +export enum MutantStatus { + Init = 'init', + Ignored = 'ignored', + NoCoverage = 'noCoverage', + Killed = 'killed', + Survived = 'survived', + TimedOut = 'timedOut', + RuntimeError = 'runtimeError', + CompileError = 'compileError', +} diff --git a/packages/typescript-checker/.eslintrc b/packages/typescript-checker/.eslintrc new file mode 100644 index 0000000000..7e107338ab --- /dev/null +++ b/packages/typescript-checker/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.js" +} \ No newline at end of file diff --git a/packages/typescript-checker/.mocharc.jsonc b/packages/typescript-checker/.mocharc.jsonc new file mode 100644 index 0000000000..a12650f47f --- /dev/null +++ b/packages/typescript-checker/.mocharc.jsonc @@ -0,0 +1,12 @@ +{ + "require": ["source-map-support/register"], + "file": [ + // This file is included first + "dist/test/helpers/setup-tests.js" + ], + "spec": [ + "dist/test/unit/**/*.js", + "dist/test/integration/**/*.js" + ], + "timeout": 10000 +} diff --git a/packages/typescript-checker/.npmignore b/packages/typescript-checker/.npmignore new file mode 100644 index 0000000000..026a43bb64 --- /dev/null +++ b/packages/typescript-checker/.npmignore @@ -0,0 +1,6 @@ +* + +!dist/src/**/*.js +!dist/src/**/*.map +!dist/src/**/*.ts +!src/**/* diff --git a/packages/typescript-checker/.npmrc b/packages/typescript-checker/.npmrc new file mode 100644 index 0000000000..9cf9495031 --- /dev/null +++ b/packages/typescript-checker/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/typescript-checker/.vscode/launch.json b/packages/typescript-checker/.vscode/launch.json new file mode 100644 index 0000000000..b251812147 --- /dev/null +++ b/packages/typescript-checker/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Unit / Integration tests", + "program": "${workspaceRoot}/../../node_modules/mocha/bin/_mocha", + "internalConsoleOptions": "openOnSessionStart", + "outFiles": [ + "${workspaceRoot}/dist/**/*.js" + ], + "skipFiles": [ + "/**" + ], + "args": [ + "--no-timeout" + ] + } + ] +} diff --git a/packages/typescript-checker/README.md b/packages/typescript-checker/README.md new file mode 100644 index 0000000000..2764a27194 --- /dev/null +++ b/packages/typescript-checker/README.md @@ -0,0 +1,84 @@ +**NOTE:** This readme describes a checker plugin set to release in Stryker v4. It is not usable yet! +Please see the [master branch](https://github.com/stryker-mutator/stryker/tree/master) for the current release of Stryker. + + +[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fstryker-mutator%2Fstryker%2Fmaster%3Fmodule%3Dtypescript)](https://dashboard.stryker-mutator.io/reports/github.com/stryker-mutator/stryker/master?module=typescript-checker) +[![Build Status](https://github.com/stryker-mutator/stryker/workflows/CI/badge.svg)](https://github.com/stryker-mutator/stryker/actions?query=workflow%3ACI+branch%3Amaster) +[![NPM](https://img.shields.io/npm/dm/@stryker-mutator/typescript-checker.svg)](https://www.npmjs.com/package/@stryker-mutator/typescript-checker) +[![Node version](https://img.shields.io/node/v/@stryker-mutator/typescript.svg)](https://img.shields.io/node/v/@stryker-mutator/typescript-checker.svg) +[![Slack Chat](https://img.shields.io/badge/slack-chat-brightgreen.svg?logo=slack)](https://join.slack.com/t/stryker-mutator/shared_invite/enQtOTUyMTYyNTg1NDQ0LTU4ODNmZDlmN2I3MmEyMTVhYjZlYmJkOThlNTY3NTM1M2QxYmM5YTM3ODQxYmJjY2YyYzllM2RkMmM1NjNjZjM) + +![Stryker](https://github.com/stryker-mutator/stryker/raw/master/stryker-80x80.png) + +# Typescript checker + +A TypeScript type checker plugin for [Stryker](https://stryker-mutator.io), the ~~JavaScript~~ *TypeScript* Mutation testing framework. +This plugin enables type checking on mutants, so you won't have to waste time on mutants which results in a type error. + +## Features + +👽 Type check each mutant. Invalid mutants will be marked as `CompileError` in your Stryker report. +🧒 Easy to setup, only your `tsconfig.json` file is needed. +🔢 Type check is done in-memory, no side effects on disk. +🎁 Support for both single typescript projects as well as projects with project references (`--build` mode). + +## Install + +First, install Stryker itself (you can follow the [quickstart on the website](https://stryker-mutator.io/quickstart.html)) + +Next, install this package: + +```bash +npm install --save-dev @stryker-mutator/typescript-checker +``` + +## Configuring + +You can configure the typescript checker in the `stryker.conf.js` (or `stryker.conf.json`) file. + +```js +// stryker.conf.json +{ + "checkers": ["typescript"], + "typescriptChecker": { + "tsconfigFile": "tsconfig.json" + } +} +``` + +### `typescriptChecker.tsconfigFile` [`string`] + +Default: `'tsconfig.json'` + +The path to your [tsconfig](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html). Project references _are supported_, `--build` mode will be enabled automatically when references are found in your tsconfig.json file. + +*Note: the following compiler options are always overridden by @stryker-mutator/typescript-checker to ovoid false positives. See [issue 391](https://github.com/stryker-mutator/stryker/issues/391#issue-259829320) for more information on this* + +```json +{ + "compilerOptions": { + "allowUnreachableCode": true, + "noUnusedLocals": false, + "noUnusedParameters": false + } +} +``` + +## Peer dependencies + +The `@stryker-mutator/typescript-checker` package for `stryker` to enable `typescript` support. As such, you should make sure you have the correct versions of its dependencies installed: + +* `typescript` +* `@stryker-mutator/core` + +For the current versions, see the `peerDependencies` section in the [package.json](https://github.com/stryker-mutator/stryker/blob/master/packages/typescript/package.json). + + +## Load the plugin + +In this plugin the `@stryker-mutator/typescript-checker`' must be loaded into Stryker. +The easiest way to achieve this, is *not have a `plugins` section* in your config file. That way, all plugins starting with `"@stryker-mutator/"` will be loaded. + +If you do decide to choose specific modules, don't forget to add `"@stryker-mutator/typescript-checker"` to the list of plugins to load. + + diff --git a/packages/typescript-checker/package.json b/packages/typescript-checker/package.json new file mode 100644 index 0000000000..9640a93cb1 --- /dev/null +++ b/packages/typescript-checker/package.json @@ -0,0 +1,45 @@ +{ + "name": "@stryker-mutator/typescript-checker", + "version": "3.2.2", + "description": "A typescript type checker plugin to be used in Stryker, the JavaScript mutation testing framework", + "main": "dist/src/index.js", + "scripts": { + "test": "nyc --exclude-after-remap=false --check-coverage --reporter=html --report-dir=reports/coverage --lines 80 --functions 80 --branches 75 npm run mocha", + "mocha": "mocha", + "stryker": "node ../core/bin/stryker run" + }, + "repository": { + "type": "git", + "url": "https://github.com/stryker-mutator/stryker", + "directory": "packages/typescript-checker" + }, + "engines": { + "node": ">=10" + }, + "publishConfig": { + "access": "public" + }, + "author": "Nico Jansen ", + "contributors": [ + "Simon de Lang ", + "Nico Jansen " + ], + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/stryker-mutator/stryker/issues" + }, + "homepage": "https://stryker-mutator.io", + "dependencies": { + "@stryker-mutator/api": "^3.2.2", + "@stryker-mutator/util": "^3.2.2", + "semver": "^7.3.2" + }, + "devDependencies": { + "@stryker-mutator/test-helpers": "^3.2.2", + "@types/semver": "^7.2.0" + }, + "peerDependencies": { + "typescript": ">=3.6", + "@stryker-mutator/core": "3.2.4" + } +} diff --git a/packages/typescript-checker/schema/typescript-checker-options.json b/packages/typescript-checker/schema/typescript-checker-options.json new file mode 100644 index 0000000000..fa97f2ec61 --- /dev/null +++ b/packages/typescript-checker/schema/typescript-checker-options.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "title": "TypescriptCheckerOptions", + "additionalProperties": false, + "properties": { + "typescriptChecker": { + "title": "TypescriptOptions", + "additionalProperties": false, + "description": "Configuration for @stryker-mutator/typescript-checker.", + "default": {}, + "type": "object", + "properties": { + "tsconfigFile": { + "description": "Location of your (root) tsconfig.json file. Project references with `--build` are supported by default.", + "type": "string", + "default": "tsconfig.json" + } + } + } + } +} diff --git a/packages/typescript-checker/src/fs/hybrid-file-system.ts b/packages/typescript-checker/src/fs/hybrid-file-system.ts new file mode 100644 index 0000000000..64204bf70c --- /dev/null +++ b/packages/typescript-checker/src/fs/hybrid-file-system.ts @@ -0,0 +1,69 @@ +import ts from 'typescript'; +import { Mutant } from '@stryker-mutator/api/core'; + +import { ScriptFile } from './script-file'; + +function toTSFileName(fileName: string) { + return fileName.replace(/\\/g, '/'); +} + +/** + * A very simple hybrid file system. + * * Readonly from disk + * * Writes in-memory + * * Hard caching + * * Ability to mutate one file + */ +export class HybridFileSystem { + private readonly files = new Map(); + private mutatedFile: ScriptFile | undefined; + + public writeFile(fileName: string, data: string) { + fileName = toTSFileName(fileName); + const existingFile = this.files.get(fileName); + if (existingFile) { + existingFile.write(data); + } else { + this.files.set(fileName, new ScriptFile(data, fileName)); + } + } + + public mutate(mutant: Pick) { + const fileName = toTSFileName(mutant.fileName); + const file = this.files.get(fileName); + if (!file) { + throw new Error(`File "${mutant.fileName}" cannot be found.`); + } + if (this.mutatedFile && this.mutatedFile !== file) { + this.mutatedFile.resetMutant(); + } + file.mutate(mutant); + this.mutatedFile = file; + } + + public watchFile(fileName: string, watcher: ts.FileWatcherCallback) { + const file = this.getFile(fileName); + if (!file) { + throw new Error(`Cannot find file ${fileName} for watching`); + } + file.watcher = watcher; + } + + public getFile(fileName: string): ScriptFile | undefined { + fileName = toTSFileName(fileName); + if (!this.files.has(fileName)) { + let content = ts.sys.readFile(fileName); + if (content) { + let modifiedTime = ts.sys.getModifiedTime!(fileName)!; + this.files.set(fileName, new ScriptFile(content, fileName, modifiedTime)); + } else { + this.files.set(fileName, undefined); + } + } + return this.files.get(fileName); + } + + public existsInMemory(fileName: string): boolean { + return !!this.files.get(toTSFileName(fileName)); + } +} diff --git a/packages/typescript-checker/src/fs/index.ts b/packages/typescript-checker/src/fs/index.ts new file mode 100644 index 0000000000..a338212607 --- /dev/null +++ b/packages/typescript-checker/src/fs/index.ts @@ -0,0 +1 @@ +export * from './hybrid-file-system'; diff --git a/packages/typescript-checker/src/fs/script-file.ts b/packages/typescript-checker/src/fs/script-file.ts new file mode 100644 index 0000000000..13e71b0ce1 --- /dev/null +++ b/packages/typescript-checker/src/fs/script-file.ts @@ -0,0 +1,40 @@ +import ts from 'typescript'; +import { Mutant } from '@stryker-mutator/api/core'; +import { FileWatcherCallback } from 'typescript'; + +export class ScriptFile { + private readonly originalContent: string; + constructor(public content: string, public fileName: string, public modifiedTime = new Date()) { + this.originalContent = content; + } + + public write(content: string) { + this.content = content; + this.touch(); + } + + public watcher: FileWatcherCallback | undefined; + + public mutate(mutant: Pick) { + this.guardMutationIsWatched(); + this.content = `${this.originalContent.substr(0, mutant.range[0])}${mutant.replacement}${this.originalContent.substr(mutant.range[1])}`; + this.touch(); + } + + public resetMutant() { + this.guardMutationIsWatched(); + this.content = this.originalContent; + this.touch(); + } + + private guardMutationIsWatched() { + if (!this.watcher) { + throw new Error(`No watcher registered for ${this.fileName}. Changes would go unnoticed`); + } + } + + private touch() { + this.modifiedTime = new Date(); + this.watcher?.(this.fileName, ts.FileWatcherEventKind.Changed); + } +} diff --git a/packages/typescript-checker/src/index.ts b/packages/typescript-checker/src/index.ts new file mode 100644 index 0000000000..5afea94ba0 --- /dev/null +++ b/packages/typescript-checker/src/index.ts @@ -0,0 +1,9 @@ +import { PluginKind, declareClassPlugin } from '@stryker-mutator/api/plugin'; + +import strykerValidationSchema from '../schema/typescript-checker-options.json'; + +import { TypescriptChecker } from './typescript-checker'; + +export const strykerPlugins = [declareClassPlugin(PluginKind.Checker, 'typescript', TypescriptChecker)]; + +export { strykerValidationSchema, TypescriptChecker }; diff --git a/packages/typescript-checker/src/tsconfig-helpers.ts b/packages/typescript-checker/src/tsconfig-helpers.ts new file mode 100644 index 0000000000..a9ecd4d0f6 --- /dev/null +++ b/packages/typescript-checker/src/tsconfig-helpers.ts @@ -0,0 +1,74 @@ +import { resolve } from 'path'; + +import ts from 'typescript'; +import semver from 'semver'; + +// Override some compiler options that have to do with code quality. When mutating, we're not interested in the resulting code quality +// See https://github.com/stryker-mutator/stryker/issues/391 for more info +const COMPILER_OPTIONS_OVERRIDES: Readonly> = Object.freeze({ + allowUnreachableCode: true, + noUnusedLocals: false, + noUnusedParameters: false, +}); + +// When we're running in 'single-project' mode, we can safely disable emit +const NO_EMIT_OPTIONS_FOR_SINGLE_PROJECT: Readonly> = Object.freeze({ + noEmit: true, + incremental: false, // incremental and composite off: https://github.com/microsoft/TypeScript/issues/36917 + composite: false, + declaration: false, +}); + +// When we're running in 'project references' mode, we need to enable declaration output +const LOW_EMIT_OPTIONS_FOR_PROJECT_REFERENCES: Readonly> = Object.freeze({ + emitDeclarationOnly: true, + noEmit: false, + declarationMap: false, +}); + +export function guardTSVersion() { + if (!semver.satisfies(ts.version, '>=3.6')) { + throw new Error(`@stryker-mutator/typescript-checker only supports typescript@3.6 our higher. Found typescript@${ts.version}`); + } +} + +/** + * Determines whether or not to use `--build` mode based on "references" being there in the config file + * @param tsconfigFileName The tsconfig file to parse + */ +export function determineBuildModeEnabled(tsconfigFileName: string) { + const tsconfigFile = ts.sys.readFile(tsconfigFileName); + if (!tsconfigFile) { + throw new Error(`File "${tsconfigFileName}" not found!`); + } + const useProjectReferences = 'references' in ts.parseConfigFileTextToJson(tsconfigFileName, tsconfigFile).config; + return useProjectReferences; +} + +/** + * Overrides some options to speed up compilation and disable some code quality checks we don't want during mutation testing + * @param parsedConfig The parsed config file + * @param useBuildMode whether or not `--build` mode is used + */ +export function overrideOptions(parsedConfig: { config?: any }, useBuildMode: boolean) { + const config = { + ...parsedConfig.config, + compilerOptions: { + ...parsedConfig.config?.compilerOptions, + ...COMPILER_OPTIONS_OVERRIDES, + ...(useBuildMode ? LOW_EMIT_OPTIONS_FOR_PROJECT_REFERENCES : NO_EMIT_OPTIONS_FOR_SINGLE_PROJECT), + }, + }; + return JSON.stringify(config); +} + +/** + * Retrieves the referenced config files based on parsed configuration + * @param parsedConfig The parsed config file + */ +export function retrieveReferencedProjects(parsedConfig: { config?: any }): string[] { + if (Array.isArray(parsedConfig.config?.references)) { + return parsedConfig.config?.references.map((reference: any) => resolve(ts.resolveProjectReferencePath(reference))); + } + return []; +} diff --git a/packages/typescript-checker/src/tsconfig.json b/packages/typescript-checker/src/tsconfig.json new file mode 100644 index 0000000000..72cd608aa5 --- /dev/null +++ b/packages/typescript-checker/src/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.settings.json", + "compilerOptions": { + "outDir": "../dist", + "rootDir": "..", + "esModuleInterop": true, + "resolveJsonModule": true, + "noImplicitThis": true, + "types": [ + "node" + ], + }, + "references": [ + { + "path": "../../api/tsconfig.src.json" + }, + { + "path": "../../util/tsconfig.src.json" + } + ], + "include": ["**/*.ts", "../src-generated/**/*.ts", "../schema/typescript-checker-options.json"] +} diff --git a/packages/typescript-checker/src/typescript-checker-with-stryker-options.ts b/packages/typescript-checker/src/typescript-checker-with-stryker-options.ts new file mode 100644 index 0000000000..20521aeaf6 --- /dev/null +++ b/packages/typescript-checker/src/typescript-checker-with-stryker-options.ts @@ -0,0 +1,5 @@ +import { StrykerOptions } from '@stryker-mutator/api/core'; + +import { TypescriptCheckerOptions } from '../src-generated/typescript-checker-options'; + +export interface TypescriptCheckerWithStrykerOptions extends StrykerOptions, TypescriptCheckerOptions {} diff --git a/packages/typescript-checker/src/typescript-checker.ts b/packages/typescript-checker/src/typescript-checker.ts new file mode 100644 index 0000000000..568347122a --- /dev/null +++ b/packages/typescript-checker/src/typescript-checker.ts @@ -0,0 +1,190 @@ +import { EOL } from 'os'; +import path from 'path'; + +import ts from 'typescript'; +import { Checker, CheckResult, CheckStatus } from '@stryker-mutator/api/check'; +import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; +import { Logger } from '@stryker-mutator/api/logging'; +import { Task, propertyPath } from '@stryker-mutator/util'; +import { Mutant, StrykerOptions } from '@stryker-mutator/api/core'; + +import { TypescriptCheckerOptions } from '../src-generated/typescript-checker-options'; + +import { HybridFileSystem } from './fs'; +import { TypescriptCheckerWithStrykerOptions } from './typescript-checker-with-stryker-options'; +import { determineBuildModeEnabled, overrideOptions, retrieveReferencedProjects, guardTSVersion } from './tsconfig-helpers'; + +const diagnosticsHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: process.cwd, + getNewLine: () => EOL, +}; + +const FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE = 6032; + +/** + * An in-memory type checker implementation which validates type errors of mutants. + */ +export class TypescriptChecker implements Checker { + private readonly fs = new HybridFileSystem(); + private currentTask: Task; + private readonly currentErrors: ts.Diagnostic[] = []; + /** + * Keep track of all tsconfig files which are read during compilation (for project references) + */ + private readonly allTSConfigFiles: Set; + + public static inject = tokens(commonTokens.logger, commonTokens.options); + private readonly tsconfigFile: string; + + constructor(private readonly logger: Logger, options: StrykerOptions) { + this.tsconfigFile = (options as TypescriptCheckerWithStrykerOptions).typescriptChecker.tsconfigFile; + this.allTSConfigFiles = new Set([path.resolve(this.tsconfigFile)]); + } + + /** + * Starts the typescript compiler and does a dry run + */ + public async init(): Promise { + guardTSVersion(); + this.guardTSConfigFileExists(); + this.currentTask = new Task(); + const buildModeEnabled = determineBuildModeEnabled(this.tsconfigFile); + const compiler = ts.createSolutionBuilderWithWatch( + ts.createSolutionBuilderWithWatchHost( + { + ...ts.sys, + readFile: (fileName) => { + const content = this.fs.getFile(fileName)?.content; + if (content && this.allTSConfigFiles.has(path.resolve(fileName))) { + return this.adjustTSConfigFile(fileName, content, buildModeEnabled); + } + return content; + }, + watchFile: (path: string, callback: ts.FileWatcherCallback) => { + this.fs.getFile(path)!.watcher = callback; + return { + close: () => { + delete this.fs.getFile(path)!.watcher; + }, + }; + }, + writeFile: (path, data) => { + this.fs.writeFile(path, data); + }, + createDirectory: () => { + // Idle, no need to create directories in the hybrid fs + }, + clearScreen() { + // idle, never clear the screen + }, + getModifiedTime: (fileName) => { + return this.fs.getFile(fileName)!.modifiedTime; + }, + watchDirectory: (): ts.FileWatcher => { + // this is used to see if new files are added to a directory. Can safely be ignored for mutation testing. + return { + close() {}, + }; + }, + }, + undefined, + (error) => this.currentErrors.push(error), + (status) => this.logDiagnostic('status')(status), + (summary) => { + this.logDiagnostic('summary')(summary); + summary.code !== FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE && this.resolveCheckResult(); + } + ), + [this.tsconfigFile], + {} + ); + compiler.build(); + const result = await this.currentTask.promise; + if (result.status === CheckStatus.CompileError) { + throw new Error(`TypeScript error(s) found in dry run compilation: ${result.reason}`); + } + } + + private guardTSConfigFileExists() { + if (!ts.sys.fileExists(this.tsconfigFile)) { + throw new Error( + `The tsconfig file does not exist at: "${path.resolve( + this.tsconfigFile + )}". Please configure the tsconfig file in your stryker.conf file using "${propertyPath( + 'typescriptChecker', + 'tsconfigFile' + )}"` + ); + } + } + + /** + * Checks whether or not a mutant results in a compile error. + * Will simply pass through if the file mutated isn't part of the typescript project + * @param mutant The mutant to check + */ + public async check(mutant: Mutant): Promise { + if (this.fs.existsInMemory(mutant.fileName)) { + this.clearCheckState(); + this.fs.mutate(mutant); + return this.currentTask.promise; + } else { + // We allow people to mutate files that are not included in this ts project + return { + status: CheckStatus.Passed, + }; + } + } + + /** + * Post processes the content of a tsconfig file. Adjusts some options for speed and alters quality options. + * @param fileName The tsconfig file name + * @param content The tsconfig content + * @param buildModeEnabled Whether or not `--build` mode is used + */ + private adjustTSConfigFile(fileName: string, content: string, buildModeEnabled: boolean) { + const parsedConfig = ts.parseConfigFileTextToJson(fileName, content); + if (parsedConfig.error) { + return content; // let the ts compiler deal with this error + } else { + for (const referencedProject of retrieveReferencedProjects(parsedConfig)) { + this.allTSConfigFiles.add(referencedProject); + } + return overrideOptions(parsedConfig, buildModeEnabled); + } + } + + /** + * Resolves the task that is currently running. Will report back the check result. + */ + private resolveCheckResult(): void { + if (this.currentErrors.length) { + const errorText = ts.formatDiagnostics(this.currentErrors, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: process.cwd, + getNewLine: () => EOL, + }); + this.currentTask.resolve({ + status: CheckStatus.CompileError, + reason: errorText, + }); + } + this.currentTask.resolve({ status: CheckStatus.Passed }); + } + + /** + * Clear state between checks + */ + private clearCheckState() { + while (this.currentErrors.pop()) { + // Idle + } + this.currentTask = new Task(); + } + private readonly logDiagnostic = (label: string) => { + return (d: ts.Diagnostic) => { + this.logger.trace(`${label} ${ts.formatDiagnostics([d], diagnosticsHost)}`); + }; + }; +} diff --git a/packages/typescript-checker/stryker.conf.js b/packages/typescript-checker/stryker.conf.js new file mode 100644 index 0000000000..c84ec1366d --- /dev/null +++ b/packages/typescript-checker/stryker.conf.js @@ -0,0 +1,8 @@ +const path = require('path'); +const settings = require('../../stryker.parent.conf'); +const moduleName = __dirname.split(path.sep).pop(); +settings.dashboard.module = moduleName; +delete settings.mochaOptions.spec; +delete settings.files; +settings.coverageAnalysis = 'perTest'; +module.exports = settings; diff --git a/packages/typescript-checker/test/helpers/factories.ts b/packages/typescript-checker/test/helpers/factories.ts new file mode 100644 index 0000000000..de13830698 --- /dev/null +++ b/packages/typescript-checker/test/helpers/factories.ts @@ -0,0 +1,8 @@ +import { TypescriptOptions } from '../../src-generated/typescript-checker-options'; + +export function createTypescriptOptions(overrides?: Partial): TypescriptOptions { + return { + tsconfigFile: 'tsconfig.json', + ...overrides, + }; +} diff --git a/packages/typescript-checker/test/helpers/setup-tests.ts b/packages/typescript-checker/test/helpers/setup-tests.ts new file mode 100644 index 0000000000..f900fedb46 --- /dev/null +++ b/packages/typescript-checker/test/helpers/setup-tests.ts @@ -0,0 +1,20 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; +import { testInjector } from '@stryker-mutator/test-helpers'; + +chai.use(sinonChai); +chai.use(chaiAsPromised); + +let originalCwd: string; + +beforeEach(() => { + originalCwd = process.cwd(); +}); + +afterEach(() => { + sinon.restore(); + testInjector.reset(); + process.chdir(originalCwd); +}); diff --git a/packages/typescript-checker/test/integration/project-references.it.spec.ts b/packages/typescript-checker/test/integration/project-references.it.spec.ts new file mode 100644 index 0000000000..51a933833d --- /dev/null +++ b/packages/typescript-checker/test/integration/project-references.it.spec.ts @@ -0,0 +1,72 @@ +import path from 'path'; +import fs from 'fs'; + +import { expect } from 'chai'; +import { Mutant, Range } from '@stryker-mutator/api/core'; +import { CheckResult, CheckStatus } from '@stryker-mutator/api/check'; +import { testInjector, factory } from '@stryker-mutator/test-helpers'; + +import { TypescriptChecker } from '../../src'; +import { createTypescriptOptions } from '../helpers/factories'; + +const resolveTestResource = (path.resolve.bind( + path, + __dirname, + '..' /* integration */, + '..' /* test */, + '..' /* dist */, + 'testResources', + 'project-references' +) as unknown) as typeof path.resolve; + +describe('Typescript checker on a project with project references', () => { + let sut: TypescriptChecker; + + beforeEach(() => { + process.chdir(resolveTestResource()); + testInjector.options.typescriptChecker = createTypescriptOptions({ tsconfigFile: 'tsconfig.root.json' }); + sut = testInjector.injector.injectClass(TypescriptChecker); + return sut.init(); + }); + + it('should not write output to disk', () => { + expect(fs.existsSync(resolveTestResource('dist')), 'Output was written to disk!').false; + }); + + it('should be able to validate a mutant', async () => { + const mutant = createMutant('src/todo.ts', 'TodoList.allTodos.push(newItem)', 'newItem ? 42 : 43'); + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + const actualResult = await sut.check(mutant); + expect(actualResult).deep.eq(expectedResult); + }); + + it('should allow unused local variables (override options)', async () => { + const mutant = createMutant('src/todo.ts', 'TodoList.allTodos.push(newItem)', '42'); + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + const actual = await sut.check(mutant); + expect(actual).deep.eq(expectedResult); + }); +}); + +const fileContents = Object.freeze({ + ['src/todo.ts']: fs.readFileSync(resolveTestResource('src', 'todo.ts'), 'utf8'), + ['test/todo.spec.ts']: fs.readFileSync(resolveTestResource('test', 'todo.spec.ts'), 'utf8'), +}); + +function createMutant(fileName: 'src/todo.ts' | 'test/todo.spec.ts', findText: string, replacement: string, offset: number = 0): Mutant { + const originalOffset: number = fileContents[fileName].indexOf(findText); + if (originalOffset === -1) { + throw new Error(`Cannot find ${findText} in ${fileName}`); + } + const range: Range = [originalOffset + offset, originalOffset + findText.length]; + return factory.mutant({ + fileName: resolveTestResource(fileName), + mutatorName: 'foo-mutator', + range, + replacement, + }); +} diff --git a/packages/typescript-checker/test/integration/single-project.it.spec.ts b/packages/typescript-checker/test/integration/single-project.it.spec.ts new file mode 100644 index 0000000000..d02581972c --- /dev/null +++ b/packages/typescript-checker/test/integration/single-project.it.spec.ts @@ -0,0 +1,130 @@ +import path from 'path'; +import fs from 'fs'; + +import { testInjector, factory } from '@stryker-mutator/test-helpers'; +import { expect } from 'chai'; +import { Mutant, Range } from '@stryker-mutator/api/core'; +import { CheckResult, CheckStatus } from '@stryker-mutator/api/check'; + +import { TypescriptChecker } from '../../src'; +import { createTypescriptOptions } from '../helpers/factories'; + +const resolveTestResource = (path.resolve.bind( + path, + __dirname, + '..' /* integration */, + '..' /* test */, + '..' /* dist */, + 'testResources', + 'single-project' +) as unknown) as typeof path.resolve; + +describe('Typescript checker on a single project', () => { + let sut: TypescriptChecker; + + beforeEach(() => { + process.chdir(resolveTestResource()); + testInjector.options.typescriptChecker = createTypescriptOptions(); + sut = testInjector.injector.injectClass(TypescriptChecker); + return sut.init(); + }); + + it('should not write output to disk', () => { + expect(fs.existsSync(resolveTestResource('dist')), 'Output was written to disk!').false; + }); + + it('should be able to validate a mutant that does not result in an error', async () => { + const mutant = createMutant('todo.ts', 'TodoList.allTodos.push(newItem)', 'newItem? 42: 43'); + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + const actual = await sut.check(mutant); + expect(actual).deep.eq(expectedResult); + }); + + it('should be able invalidate a mutant that does result in a compile error', async () => { + const mutant = createMutant('todo.ts', 'TodoList.allTodos.push(newItem)', '"This should not be a string 🙄"'); + const actual = await sut.check(mutant); + expect(actual.status).deep.eq(CheckStatus.CompileError); + expect(actual.reason).has.string('todo.ts(15,9): error TS2322'); + }); + + it('should be able validate a mutant that does not result in a compile error after a compile error', async () => { + // Arrange + const mutantCompileError = createMutant('todo.ts', 'TodoList.allTodos.push(newItem)', '"This should not be a string 🙄"'); + const mutantWithoutError = createMutant('todo.ts', 'return TodoList.allTodos', '[]', 7); + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + + // Act + await sut.check(mutantCompileError); + const actual = await sut.check(mutantWithoutError); + + // Assert + expect(actual).deep.eq(expectedResult); + }); + + it('should be able to invalidate a mutant that results in an error in a different file', async () => { + const result = await sut.check(createMutant('todo.ts', 'return totalCount;', '')); + expect(result.status).eq(CheckStatus.CompileError); + expect(result.reason).has.string('todo.spec.ts(4,7): error TS2322'); + }); + + it('should be able to validate a mutant after a mutant in a different file resulted in a transpile error', async () => { + // Act + await sut.check(createMutant('todo.ts', 'return totalCount;', '')); + const result = await sut.check(createMutant('todo.spec.ts', "'Mow lawn'", "'this is valid, right?'")); + + // Assert + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + expect(result).deep.eq(expectedResult); + }); + + it('should be allow mutations in unrelated files', async () => { + // Act + const result = await sut.check(createMutant('not-type-checked.js', 'bar', 'baz')); + + // Assert + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + expect(result).deep.eq(expectedResult); + }); + + it('should allow unused local variables (override options)', async () => { + const mutant = createMutant('todo.ts', 'TodoList.allTodos.push(newItem)', '42'); + const expectedResult: CheckResult = { + status: CheckStatus.Passed, + }; + const actual = await sut.check(mutant); + expect(actual).deep.eq(expectedResult); + }); +}); + +const fileContents = Object.freeze({ + ['todo.ts']: fs.readFileSync(resolveTestResource('src', 'todo.ts'), 'utf8'), + ['todo.spec.ts']: fs.readFileSync(resolveTestResource('src', 'todo.spec.ts'), 'utf8'), + ['not-type-checked.js']: fs.readFileSync(resolveTestResource('src', 'not-type-checked.js'), 'utf8'), +}); + +function createMutant( + fileName: 'todo.ts' | 'todo.spec.ts' | 'not-type-checked.js', + findText: string, + replacement: string, + offset: number = 0 +): Mutant { + const originalOffset: number = fileContents[fileName].indexOf(findText); + if (originalOffset === -1) { + throw new Error(`Cannot find ${findText} in ${fileName}`); + } + const range: Range = [originalOffset + offset, originalOffset + findText.length]; + return factory.mutant({ + fileName: resolveTestResource('src', fileName), + mutatorName: 'foo-mutator', + range, + replacement, + }); +} diff --git a/packages/typescript-checker/test/integration/typescript-checkers-errors.it.spec.ts b/packages/typescript-checker/test/integration/typescript-checkers-errors.it.spec.ts new file mode 100644 index 0000000000..18e2a236a7 --- /dev/null +++ b/packages/typescript-checker/test/integration/typescript-checkers-errors.it.spec.ts @@ -0,0 +1,46 @@ +import path from 'path'; + +import { testInjector } from '@stryker-mutator/test-helpers'; +import { expect } from 'chai'; + +import { createTypescriptOptions } from '../helpers/factories'; +import { TypescriptChecker } from '../../src'; + +const resolveTestResource = (path.resolve.bind( + path, + __dirname, + '..' /* integration */, + '..' /* test */, + '..' /* dist */, + 'testResources', + 'errors' +) as unknown) as typeof path.resolve; + +describe('Typescript checker errors', () => { + let sut: TypescriptChecker; + + beforeEach(() => { + testInjector.options.typescriptChecker = createTypescriptOptions(); + sut = testInjector.injector.injectClass(TypescriptChecker); + }); + + it('should reject initialization if initial compilation failed', async () => { + process.chdir(resolveTestResource('compile-error')); + await expect(sut.init()).rejectedWith('TypeScript error(s) found in dry run compilation: add.ts(2,3): error TS2322:'); + }); + + it('should reject initialization if tsconfig was invalid', async () => { + process.chdir(resolveTestResource('invalid-tsconfig')); + await expect(sut.init()).rejectedWith('TypeScript error(s) found in dry run compilation: tsconfig.json(1,1): error TS1005:'); + }); + + it("should reject when tsconfig file doesn't exist", async () => { + process.chdir(resolveTestResource('empty-dir')); + await expect(sut.init()).rejectedWith( + `The tsconfig file does not exist at: "${resolveTestResource( + 'empty-dir', + 'tsconfig.json' + )}". Please configure the tsconfig file in your stryker.conf file using "typescriptChecker.tsconfigFile"` + ); + }); +}); diff --git a/packages/typescript-checker/test/tsconfig.json b/packages/typescript-checker/test/tsconfig.json new file mode 100644 index 0000000000..c3f30c9d6a --- /dev/null +++ b/packages/typescript-checker/test/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.settings.json", + "compilerOptions": { + "outDir": "../dist/test", + "rootDir": ".", + "esModuleInterop": true, + "types": [ + "mocha", + "node" + ] + }, + "references": [ + { + "path": "../src" + }, + { + "path": "../../test-helpers/tsconfig.src.json" + } + ] +} diff --git a/packages/typescript-checker/test/unit/fs/hybrid-file-system.spec.ts b/packages/typescript-checker/test/unit/fs/hybrid-file-system.spec.ts new file mode 100644 index 0000000000..462865e4a0 --- /dev/null +++ b/packages/typescript-checker/test/unit/fs/hybrid-file-system.spec.ts @@ -0,0 +1,180 @@ +import sinon from 'sinon'; +import ts from 'typescript'; +import { expect } from 'chai'; + +import { HybridFileSystem } from '../../../src/fs'; + +describe('fs', () => { + describe(HybridFileSystem.name, () => { + class Helper { + public readFileStub = sinon.stub(ts.sys, 'readFile'); + public getModifiedTimeStub = sinon.stub(ts.sys, 'getModifiedTime'); + } + let sut: HybridFileSystem; + let helper: Helper; + beforeEach(() => { + helper = new Helper(); + sut = new HybridFileSystem(); + }); + + describe(HybridFileSystem.prototype.writeFile.name, () => { + it('should create a new in-memory file', () => { + sut.writeFile('add.js', 'a + b'); + const file = sut.getFile('add.js'); + expect(file).ok; + expect(file!.content).eq('a + b'); + expect(file!.fileName).eq('add.js'); + }); + + it('should override an existing file', () => { + sut.writeFile('add.js', 'a + b'); + sut.writeFile('add.js', 'a - b'); + const file = sut.getFile('add.js'); + expect(file!.content).eq('a - b'); + }); + + it('should convert path separator to forward slashes', () => { + sut.writeFile('test\\foo\\a.js', 'a'); + const actual = sut.getFile('test/foo/a.js'); + expect(actual).ok; + expect(actual!.content).eq('a'); + }); + }); + + describe(HybridFileSystem.prototype.getFile.name, () => { + it("should read the file from disk if it wasn't loaded yet", () => { + // Arrange + const modifiedTime = new Date(2010, 1, 1); + helper.readFileStub.returns('content from disk'); + helper.getModifiedTimeStub.returns(modifiedTime); + + // Act + const actual = sut.getFile('foo.js'); + + // Assert + expect(helper.readFileStub).calledWith('foo.js'); + expect(helper.getModifiedTimeStub).calledWith('foo.js'); + expect(actual).ok; + expect(actual!.fileName).eq('foo.js'); + expect(actual!.content).eq('content from disk'); + expect(actual!.modifiedTime).eq(modifiedTime); + }); + + it('should convert path separator to forward slashes', () => { + helper.readFileStub.returns('content from disk'); + helper.getModifiedTimeStub.returns(new Date(2010, 1, 1)); + sut.getFile('test\\foo\\a.js'); + expect(helper.readFileStub).calledWith('test/foo/a.js'); + expect(helper.getModifiedTimeStub).calledWith('test/foo/a.js'); + }); + + it("should cache a file that doesn't exists", () => { + sut.getFile('not-exists.js'); + sut.getFile('not-exists.js'); + expect(helper.readFileStub).calledOnce; + }); + }); + + describe(HybridFileSystem.prototype.watchFile.name, () => { + it('should register a watcher', () => { + // Arrange + helper.readFileStub.returns('foobar'); + const watcherCallback = sinon.stub(); + + // Act + sut.watchFile('foo.js', watcherCallback); + sut.writeFile('foo.js', 'some-content'); + + // Asset + expect(watcherCallback).calledWith('foo.js', ts.FileWatcherEventKind.Changed); + }); + + it('should convert path separator to forward slashes', () => { + helper.readFileStub.returns('content from disk'); + helper.getModifiedTimeStub.returns(new Date(2010, 1, 1)); + sut.watchFile('test\\foo\\a.js', sinon.stub()); + expect(helper.readFileStub).calledWith('test/foo/a.js'); + }); + + it("should throw if file doesn't exist", () => { + expect(() => sut.watchFile('not-exists.js', sinon.stub())).throws('Cannot find file not-exists.js for watching'); + }); + }); + + describe(HybridFileSystem.prototype.mutate.name, () => { + it('should mutate the file in-memory', () => { + // Arrange + helper.readFileStub.returns('a + b'); + sut.watchFile('a.js', sinon.stub()); + + // Act + sut.mutate({ fileName: 'a.js', range: [2, 3], replacement: '-' }); + + // Assert + expect(sut.getFile('a.js')!.content).eq('a - b'); + }); + + it('should convert path separator to forward slashes', () => { + helper.readFileStub.returns('a + b'); + sut.watchFile('test/foo/a.js', sinon.stub()); + sut.mutate({ fileName: 'test\\foo\\a.js', range: [2, 3], replacement: '-' }); + expect(sut.getFile('test/foo/a.js')!.content).eq('a - b'); + }); + + it('should notify the watcher', () => { + // Arrange + const watcher = sinon.stub(); + helper.readFileStub.returns('a + b'); + sut.watchFile('a.js', watcher); + + // Act + sut.mutate({ fileName: 'a.js', range: [2, 3], replacement: '-' }); + + // Assert + expect(watcher).calledWith('a.js', ts.FileWatcherEventKind.Changed); + }); + + it('should reset previously mutated file', () => { + // Arrange + helper.readFileStub.withArgs('a.js').returns('a + b').withArgs('b.js').returns('"foo" + "bar"'); + sut.watchFile('a.js', sinon.stub()); + sut.watchFile('b.js', sinon.stub()); + + // Act + sut.mutate({ fileName: 'a.js', range: [2, 3], replacement: '-' }); + sut.mutate({ fileName: 'b.js', range: [6, 7], replacement: '-' }); + + // Assert + expect(sut.getFile('a.js')!.content).eq('a + b'); + expect(sut.getFile('b.js')!.content).eq('"foo" - "bar"'); + }); + + it("should throw if file doesn't exist", () => { + expect(() => sut.mutate({ fileName: 'a.js', range: [2, 3], replacement: '-' })).throws('File "a.js" cannot be found.'); + }); + }); + + describe(HybridFileSystem.prototype.existsInMemory.name, () => { + it('should return true if it exists', () => { + sut.writeFile('a.js', 'a + b'); + expect(sut.existsInMemory('a.js')).true; + }); + + it('should return false if it does not exist', () => { + sut.writeFile('b.js', 'a + b'); + expect(sut.existsInMemory('a.js')).false; + }); + + it('should return false it is cached to not exist', () => { + helper.readFileStub.returns(undefined); + sut.getFile('a.js'); // caches that it doesn't exists + expect(sut.existsInMemory('a.js')).false; + }); + + it('should convert path separator to forward slashes', () => { + sut.writeFile('test/foo/a.js', 'foobar'); + expect(sut.existsInMemory('test\\foo\\a.js')).true; + }); + }); + }); +}); diff --git a/packages/typescript-checker/test/unit/fs/script-file.spec.ts b/packages/typescript-checker/test/unit/fs/script-file.spec.ts new file mode 100644 index 0000000000..220eb6bc51 --- /dev/null +++ b/packages/typescript-checker/test/unit/fs/script-file.spec.ts @@ -0,0 +1,139 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import ts from 'typescript'; + +import { ScriptFile } from '../../../src/fs/script-file'; + +describe('fs', () => { + describe(ScriptFile.name, () => { + beforeEach(() => { + sinon.useFakeTimers(); + }); + describe('constructor', () => { + it('should reflect content, name and modified date', () => { + const modifiedTime = new Date(2010, 1, 1, 2, 3, 4, 4); + const sut = new ScriptFile('foo()', 'foo.js', modifiedTime); + expect(sut.fileName).eq('foo.js'); + expect(sut.content).eq('foo()'); + expect(sut.modifiedTime).eq(modifiedTime); + }); + + it('should default modifiedDate to now', () => { + const now = new Date(2010, 1, 2, 3, 4, 5, 6); + sinon.clock.setSystemTime(now); + const sut = new ScriptFile('', ''); + expect(sut.modifiedTime.valueOf()).eq(new Date(2010, 1, 2, 3, 4, 5, 6).valueOf()); + }); + }); + + describe(ScriptFile.prototype.mutate.name, () => { + let sut: ScriptFile; + beforeEach(() => { + sut = new ScriptFile('add(a, b) { return a + b };', 'add.js', new Date(2010, 1, 1)); + sut.watcher = sinon.stub(); + }); + + it('should throw when no watcher is registered', () => { + sut.watcher = undefined; + expect(() => sut.mutate({ range: [21, 22], replacement: '-' })).throws('No watcher registered for add.js. Changes would go unnoticed'); + }); + + it('should mutate the current content', () => { + sut.mutate({ range: [21, 22], replacement: '-' }); + expect(sut.content).eq('add(a, b) { return a - b };'); + }); + + it('should update the modified date', () => { + // Arrange + const now = new Date(2015, 1, 2, 3, 4, 5, 6); + sinon.clock.setSystemTime(now); + + // Act + sut.mutate({ range: [1, 2], replacement: '' }); + + // Assert + expect(sut.modifiedTime).deep.eq(now); + }); + + it('should notify the file system watcher', () => { + sut.mutate({ range: [1, 2], replacement: '' }); + expect(sut.watcher).calledWith('add.js', ts.FileWatcherEventKind.Changed); + }); + }); + + describe(ScriptFile.prototype.resetMutant.name, () => { + let sut: ScriptFile; + + beforeEach(() => { + sut = new ScriptFile('add(a, b) { return a + b };', 'add.js', new Date(2010, 1, 1)); + sut.watcher = sinon.stub(); + }); + + it('should reset the content after a mutation', () => { + sut.mutate({ replacement: 'replaces', range: [0, sut.content.length] }); + sut.resetMutant(); + expect(sut.content).eq('add(a, b) { return a + b };'); + }); + + it('should throw when no watcher is registered', () => { + sut.watcher = undefined; + expect(() => sut.resetMutant()).throws('No watcher registered for add.js. Changes would go unnoticed'); + }); + + it('should reset the content after two mutations', () => { + sut.mutate({ replacement: 'replaces', range: [0, sut.content.length] }); + sut.mutate({ replacement: 'replaced a second time', range: [0, sut.content.length] }); + sut.resetMutant(); + expect(sut.content).eq('add(a, b) { return a + b };'); + }); + + it('should notify the file system watcher', () => { + sut.resetMutant(); + expect(sut.watcher).calledWith('add.js', ts.FileWatcherEventKind.Changed); + }); + + it('should update the modified date', () => { + // Arrange + const now = new Date(2015, 1, 2, 3, 4, 5, 6); + sinon.clock.setSystemTime(now); + + // Act + sut.resetMutant(); + + // Assert + expect(sut.modifiedTime).deep.eq(now); + }); + }); + + describe(ScriptFile.prototype.write.name, () => { + let sut: ScriptFile; + + beforeEach(() => { + sut = new ScriptFile('add(a, b) { return a + b };', 'add.js', new Date(2010, 1, 1)); + }); + + it('should write to the content', () => { + sut.write('overridden'); + expect(sut.content).eq('overridden'); + }); + + it('should inform the fs watcher', () => { + sut.watcher = sinon.stub(); + sut.write('overridden'); + expect(sut.watcher).calledWith('add.js', ts.FileWatcherEventKind.Changed); + }); + + it('should update the modified date', () => { + // Arrange + const now = new Date(2015, 1, 2, 3, 4, 5, 6); + sinon.clock.setSystemTime(now); + + // Act + sut.write('overridden'); + + // Assert + expect(sut.modifiedTime).deep.eq(now); + }); + }); + }); +}); diff --git a/packages/typescript-checker/test/unit/typescript-helpers.spec.ts b/packages/typescript-checker/test/unit/typescript-helpers.spec.ts new file mode 100644 index 0000000000..34db146d0e --- /dev/null +++ b/packages/typescript-checker/test/unit/typescript-helpers.spec.ts @@ -0,0 +1,135 @@ +import path from 'path'; + +import sinon from 'sinon'; +import ts from 'typescript'; +import { expect } from 'chai'; + +import { determineBuildModeEnabled, overrideOptions, retrieveReferencedProjects, guardTSVersion } from '../../src/tsconfig-helpers'; + +describe('typescript-helpers', () => { + describe(determineBuildModeEnabled.name, () => { + let readFileStub: sinon.SinonStub; + + beforeEach(() => { + readFileStub = sinon.stub(ts.sys, 'readFile'); + }); + + it('should throw an error if the tsconfig file could not be found', () => { + expect(() => determineBuildModeEnabled('tsconfig.json')).throws('File "tsconfig.json" not found'); + }); + + it('should return true if the tsconfig file has references', () => { + readFileStub.returns('{ "references": [] }'); + expect(determineBuildModeEnabled('foo.json')).true; + expect(readFileStub).calledWith('foo.json'); + }); + + it('should return true if the tsconfig file has no references', () => { + readFileStub.returns('{ "compilerOptions": {} }'); + expect(determineBuildModeEnabled('foo.json')).false; + }); + }); + + describe(overrideOptions.name, () => { + it('should allow unreachable and unused code', () => { + expect(JSON.parse(overrideOptions({ config: {} }, false)).compilerOptions).deep.include({ + allowUnreachableCode: true, + noUnusedLocals: false, + noUnusedParameters: false, + }); + expect( + JSON.parse( + overrideOptions({ config: { compilerOptions: { allowUnreachableCode: false, noUnusedLocals: true, noUnusedParameters: true } } }, false) + ).compilerOptions + ).deep.include({ + allowUnreachableCode: true, + noUnusedLocals: false, + noUnusedParameters: false, + }); + }); + + it('should set --noEmit options when `--build` mode is off', () => { + expect(JSON.parse(overrideOptions({ config: {} }, false)).compilerOptions).deep.include({ + noEmit: true, + incremental: false, + composite: false, + declaration: false, + }); + expect( + JSON.parse( + overrideOptions( + { + config: { + compilerOptions: { + noEmit: false, + incremental: true, + composite: true, + declaration: true, + }, + }, + }, + false + ) + ).compilerOptions + ).deep.include({ + noEmit: true, + incremental: false, + composite: false, + declaration: false, + }); + }); + + it('should set --emitDeclarationOnly options when `--build` mode is on', () => { + expect(JSON.parse(overrideOptions({ config: {} }, true)).compilerOptions).deep.include({ + emitDeclarationOnly: true, + noEmit: false, + declarationMap: false, + }); + expect( + JSON.parse( + overrideOptions( + { + config: { + compilerOptions: { + emitDeclarationOnly: false, + noEmit: true, + declarationMap: true, + }, + }, + }, + true + ) + ).compilerOptions + ).deep.include({ + emitDeclarationOnly: true, + noEmit: false, + declarationMap: false, + }); + }); + }); + + describe(retrieveReferencedProjects.name, () => { + it('should result in an empty array when references are missing', () => { + expect(retrieveReferencedProjects({ config: { compilerOptions: {} } })).deep.eq([]); + }); + + it('should retrieve referenced projects', () => { + expect(retrieveReferencedProjects({ config: { references: [{ path: 'some.json' }] } })).deep.eq([path.resolve('some.json')]); + }); + }); + + describe(guardTSVersion.name, () => { + it('should throw if typescript@2.5.0', () => { + sinon.stub(ts, 'version').value('3.5.0'); + expect(guardTSVersion).throws('@stryker-mutator/typescript-checker only supports typescript@3.6 our higher. Found typescript@3.5.0'); + }); + it('should not throw if typescript@3.6.0', () => { + sinon.stub(ts, 'version').value('3.6.0'); + expect(guardTSVersion).not.throws(); + }); + it('should not throw if typescript@4.0.0', () => { + sinon.stub(ts, 'version').value('4.0.0'); + expect(guardTSVersion).not.throws(); + }); + }); +}); diff --git a/packages/typescript-checker/testResources/errors/compile-error/add.ts b/packages/typescript-checker/testResources/errors/compile-error/add.ts new file mode 100644 index 0000000000..5c40274cca --- /dev/null +++ b/packages/typescript-checker/testResources/errors/compile-error/add.ts @@ -0,0 +1,3 @@ +function add(a: number, b: number): string { + return a + b; +} diff --git a/packages/typescript-checker/testResources/errors/compile-error/tsconfig.json b/packages/typescript-checker/testResources/errors/compile-error/tsconfig.json new file mode 100644 index 0000000000..b8a31e14ea --- /dev/null +++ b/packages/typescript-checker/testResources/errors/compile-error/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "ES5", + "types": [] + } +} diff --git a/packages/typescript-checker/testResources/errors/empty-dir/.gitkeep b/packages/typescript-checker/testResources/errors/empty-dir/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/typescript-checker/testResources/errors/invalid-tsconfig/tsconfig.json b/packages/typescript-checker/testResources/errors/invalid-tsconfig/tsconfig.json new file mode 100644 index 0000000000..072ca9248e --- /dev/null +++ b/packages/typescript-checker/testResources/errors/invalid-tsconfig/tsconfig.json @@ -0,0 +1 @@ +invalid tsconfig file diff --git a/packages/typescript-checker/testResources/project-references/src/todo.ts b/packages/typescript-checker/testResources/project-references/src/todo.ts new file mode 100644 index 0000000000..7ac1dd6631 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/src/todo.ts @@ -0,0 +1,23 @@ +export interface ITodo { + name: string; + description: string; + completed: boolean; +} + +class Todo implements ITodo { + constructor(public name: string, public description: string, public completed: boolean) { } +} + +export class TodoList { + public static allTodos: Todo[] = []; + createTodoItem(name: string, description: string) { + let newItem = new Todo(name, description, false); + let totalCount: number = TodoList.allTodos.push(newItem); + return totalCount; + } + + allTodoItems(): ITodo[] { + return TodoList.allTodos; + } +} + diff --git a/packages/typescript-checker/testResources/project-references/src/tsconfig.json b/packages/typescript-checker/testResources/project-references/src/tsconfig.json new file mode 100644 index 0000000000..af64ba5a29 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/src/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.settings", + "compilerOptions": { + "outDir": "../dist/src" + } +} diff --git a/packages/typescript-checker/testResources/project-references/test/todo.spec.ts b/packages/typescript-checker/testResources/project-references/test/todo.spec.ts new file mode 100644 index 0000000000..4d0aa4f7a2 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/test/todo.spec.ts @@ -0,0 +1,11 @@ +import { TodoList } from '../src/todo'; + +const list = new TodoList(); +const n: number = list.createTodoItem('Mow lawn', 'Mow moving forward.') +console.log(n); + +function addTodo(name = 'test', description = 'test') { + list.createTodoItem(name, description); +} + +addTodo(); diff --git a/packages/typescript-checker/testResources/project-references/test/tsconfig.json b/packages/typescript-checker/testResources/project-references/test/tsconfig.json new file mode 100644 index 0000000000..22f321ef72 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.settings", + "compilerOptions": { + "outDir": "../dist/test" + }, + "references": [ + { "path": "../src" } + ] +} diff --git a/packages/typescript-checker/testResources/project-references/tsconfig.root.json b/packages/typescript-checker/testResources/project-references/tsconfig.root.json new file mode 100644 index 0000000000..d54e2806ef --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/tsconfig.root.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./src" }, + { "path": "./test" } + ] +} diff --git a/packages/typescript-checker/testResources/project-references/tsconfig.settings.json b/packages/typescript-checker/testResources/project-references/tsconfig.settings.json new file mode 100644 index 0000000000..447ab8f8ac --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/tsconfig.settings.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es5", + "esModuleInterop": true, + "moduleResolution": "node", + "module": "commonjs", + "composite": true, + "declaration": true, + "declarationMap": true, + + // These settings should be overridden by the typescript checker + "noUnusedLocals": true, + "noUnusedParameters": true, + + "types": [] + } +} diff --git a/packages/typescript-checker/testResources/single-project/src/not-type-checked.js b/packages/typescript-checker/testResources/single-project/src/not-type-checked.js new file mode 100644 index 0000000000..74aa322268 --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/src/not-type-checked.js @@ -0,0 +1 @@ +const foo = 'bar'; diff --git a/packages/typescript-checker/testResources/single-project/src/todo.spec.ts b/packages/typescript-checker/testResources/single-project/src/todo.spec.ts new file mode 100644 index 0000000000..7a3ec6a0c0 --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/src/todo.spec.ts @@ -0,0 +1,12 @@ +import { TodoList } from './todo'; + +const list = new TodoList(); +const n: number = list.createTodoItem('Mow lawn', 'Mow moving forward.') +console.log(n); + +function addTodo(name = 'test', description = 'test') { + list.createTodoItem(name, description); +} + + +addTodo(); diff --git a/packages/typescript-checker/testResources/single-project/src/todo.ts b/packages/typescript-checker/testResources/single-project/src/todo.ts new file mode 100644 index 0000000000..7ac1dd6631 --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/src/todo.ts @@ -0,0 +1,23 @@ +export interface ITodo { + name: string; + description: string; + completed: boolean; +} + +class Todo implements ITodo { + constructor(public name: string, public description: string, public completed: boolean) { } +} + +export class TodoList { + public static allTodos: Todo[] = []; + createTodoItem(name: string, description: string) { + let newItem = new Todo(name, description, false); + let totalCount: number = TodoList.allTodos.push(newItem); + return totalCount; + } + + allTodoItems(): ITodo[] { + return TodoList.allTodos; + } +} + diff --git a/packages/typescript-checker/testResources/single-project/tsconfig.json b/packages/typescript-checker/testResources/single-project/tsconfig.json new file mode 100644 index 0000000000..92e62065bc --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es5", + "esModuleInterop": true, + "moduleResolution": "node", + "module": "commonjs", + "outDir": "dist", + + // These settings should be overridden by the typescript checker + "noUnusedLocals": true, + "noUnusedParameters": true, + + "types": [] + } +} diff --git a/packages/typescript-checker/tsconfig.json b/packages/typescript-checker/tsconfig.json new file mode 100644 index 0000000000..d5c40e5337 --- /dev/null +++ b/packages/typescript-checker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./src" + }, + { + "path": "./test" + } + ] +} diff --git a/packages/typescript-checker/tsconfig.stryker.json b/packages/typescript-checker/tsconfig.stryker.json new file mode 100644 index 0000000000..3338399303 --- /dev/null +++ b/packages/typescript-checker/tsconfig.stryker.json @@ -0,0 +1,11 @@ +{ + "extends": "./test/tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": [ + "src", + "test" + ], +} diff --git a/tsconfig.json b/tsconfig.json index 77b67225ca..6b03636b24 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ { "path": "packages/mocha-runner" }, { "path": "packages/mutator-specification" }, { "path": "packages/typescript" }, + { "path": "packages/typescript-checker" }, { "path": "packages/vue-mutator" }, { "path": "packages/wct-runner" }, { "path": "packages/webpack-transpiler" }, diff --git a/workspace.code-workspace b/workspace.code-workspace index 1391450dbd..f25ee2c2d7 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -56,6 +56,10 @@ "name": "typescript", "path": "packages/typescript" }, + { + "name": "typescript-checker", + "path": "packages/typescript-checker" + }, { "name": "vue-mutator", "path": "packages/vue-mutator"