diff --git a/docs/typescript-checker.md b/docs/typescript-checker.md index bfe366edf4..3fc3b04a34 100644 --- a/docs/typescript-checker.md +++ b/docs/typescript-checker.md @@ -30,7 +30,10 @@ You can configure the typescript checker in the `stryker.conf.js` (or `stryker.c ```json { "checkers": ["typescript"], - "tsconfigFile": "tsconfig.json" + "tsconfigFile": "tsconfig.json", + "typeScriptChecker": { + "prioritizePerformanceOverAccuracy": true + } } ``` @@ -52,6 +55,12 @@ _Note: the following compiler options are always overridden by @stryker-mutator/ } ``` +### `typeScriptChecker.prioritizePerformanceOverAccuracy` [`boolean`] + +Default: `true` + +Sets the performance strategy for the typescript-checker. Defaults to `true` which the fastest strategy with the consequence of losing some accuracy. The accuracy that is lost comes down to having mutants with a status other than `CompileError` while they should have this status. This result in a report that may not be 100% accurate. Setting this option to `false` results in an accurate report but may take (way) longer. + ## 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: diff --git a/e2e/test/typescript-project-references/package.json b/e2e/test/typescript-project-references/package.json new file mode 100644 index 0000000000..f287d3fc88 --- /dev/null +++ b/e2e/test/typescript-project-references/package.json @@ -0,0 +1,24 @@ +{ + "name": "typescript-project-references", + "version": "0.0.0", + "private": true, + "description": "A module to perform an integration test", + "main": "index.js", + "scripts": { + "clean": "rimraf dist", + "prebuild": "npm run clean", + "build": "tsc -b tsconfig.json", + "pretest:unit": "npm run build", + "test:unit": "mocha", + "pretest": "rimraf \"reports\" \"dist\" \"stryker.log\"", + "test": "stryker run", + "posttest": "mocha --no-config --no-package --timeout 0 verify/verify.js" + }, + "mocha": { + "spec": [ + "test/**/*.js" + ] + }, + "author": "", + "license": "ISC" +} diff --git a/e2e/test/typescript-project-references/src/core/index.ts b/e2e/test/typescript-project-references/src/core/index.ts new file mode 100644 index 0000000000..407ccaaa23 --- /dev/null +++ b/e2e/test/typescript-project-references/src/core/index.ts @@ -0,0 +1,5 @@ +import { count } from '../utils/math.js'; + +export function countArrayLength(todo: any[]): number { + return count(todo); +} diff --git a/e2e/test/typescript-project-references/src/core/job.ts b/e2e/test/typescript-project-references/src/core/job.ts new file mode 100644 index 0000000000..41bc38f3b9 --- /dev/null +++ b/e2e/test/typescript-project-references/src/core/job.ts @@ -0,0 +1,7 @@ +import { toUpperCase } from '../utils/text.js'; + +export function start(): string { + const logText = "Starting job"; + console.log(toUpperCase(logText)); + return logText; +} diff --git a/e2e/test/typescript-project-references/src/core/tsconfig.json b/e2e/test/typescript-project-references/src/core/tsconfig.json new file mode 100644 index 0000000000..d3f8804bed --- /dev/null +++ b/e2e/test/typescript-project-references/src/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.settings", + "compilerOptions": { + "outDir": "../dist/src" + }, + "references": [ + { "path": "../utils" } + ] +} diff --git a/e2e/test/typescript-project-references/src/core/tsconfig.tsbuildinfo b/e2e/test/typescript-project-references/src/core/tsconfig.tsbuildinfo new file mode 100644 index 0000000000..436aea16eb --- /dev/null +++ b/e2e/test/typescript-project-references/src/core/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"program":{"fileNames":["../../../../node_modules/typescript/lib/lib.d.ts","../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","./index.ts","./job.ts"],"fileInfos":["2dc8c927c9c162a773c6bb3cdc4f3286c23f10eedc67414028f9cb5951610f60",{"version":"f20c05dbfe50a208301d2a1da37b9931bce0466eb5a1f4fe240971b4ecc82b67","affectsGlobalScope":true},{"version":"9b087de7268e4efc5f215347a62656663933d63c0b1d7b624913240367b999ea","affectsGlobalScope":true},{"version":"7fac8cb5fc820bc2a59ae11ef1c5b38d3832c6d0dfaec5acdb5569137d09a481","affectsGlobalScope":true},{"version":"097a57355ded99c68e6df1b738990448e0bf170e606707df5a7c0481ff2427cd","affectsGlobalScope":true},{"version":"c9b6bdd48b8bdb8d8e7690c7cc18897a494b6ab17dc58083dacfaf14b846ab4f","signature":"40b6409b8d0dced1f6c3964012b7a7c1cd50e24c3242095d1c8cfc6cabe8bd31"},{"version":"e4c28c497fe6cc6364b113c181c32ba58e70f02d824295e72b15d9570b403104","signature":"9be66c79f48b4876970daed5167e069d7f12f1a1ca616ecaa0ca8280946344ca"}],"options":{"composite":true,"declaration":true,"declarationMap":true,"module":1,"noUnusedLocals":true,"noUnusedParameters":true,"strict":true,"target":1},"referencedMap":[],"exportedModulesMap":[],"semanticDiagnosticsPerFile":[1,3,2,5,4],"changeFileSet":[6,7],"latestChangedDtsFile":"./job.d.ts"},"version":"4.8.4"} \ No newline at end of file diff --git a/e2e/test/typescript-project-references/src/utils/math.ts b/e2e/test/typescript-project-references/src/utils/math.ts new file mode 100644 index 0000000000..23cbcf7eda --- /dev/null +++ b/e2e/test/typescript-project-references/src/utils/math.ts @@ -0,0 +1,3 @@ +export function count(array: any[]) { + return array.length; +} diff --git a/e2e/test/typescript-project-references/src/utils/text.ts b/e2e/test/typescript-project-references/src/utils/text.ts new file mode 100644 index 0000000000..33d172474d --- /dev/null +++ b/e2e/test/typescript-project-references/src/utils/text.ts @@ -0,0 +1,3 @@ +export function toUpperCase(text: string) { + return text.toUpperCase(); +} diff --git a/e2e/test/typescript-project-references/src/utils/tsconfig.json b/e2e/test/typescript-project-references/src/utils/tsconfig.json new file mode 100644 index 0000000000..e3eb572925 --- /dev/null +++ b/e2e/test/typescript-project-references/src/utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.settings", + "compilerOptions": { + "outDir": "../dist/utils", + } +} diff --git a/e2e/test/typescript-project-references/src/utils/tsconfig.tsbuildinfo b/e2e/test/typescript-project-references/src/utils/tsconfig.tsbuildinfo new file mode 100644 index 0000000000..768ec50cbc --- /dev/null +++ b/e2e/test/typescript-project-references/src/utils/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"program":{"fileNames":["../../../../node_modules/typescript/lib/lib.d.ts","../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","./math.ts","./text.ts"],"fileInfos":["2dc8c927c9c162a773c6bb3cdc4f3286c23f10eedc67414028f9cb5951610f60",{"version":"f20c05dbfe50a208301d2a1da37b9931bce0466eb5a1f4fe240971b4ecc82b67","affectsGlobalScope":true},{"version":"9b087de7268e4efc5f215347a62656663933d63c0b1d7b624913240367b999ea","affectsGlobalScope":true},{"version":"7fac8cb5fc820bc2a59ae11ef1c5b38d3832c6d0dfaec5acdb5569137d09a481","affectsGlobalScope":true},{"version":"097a57355ded99c68e6df1b738990448e0bf170e606707df5a7c0481ff2427cd","affectsGlobalScope":true},{"version":"6198e7d4a43aabb174a72ec9f0e8d2962912ad59ad90010aac3930868a8f62a4","signature":"0400cb85cef49e897c47df13e38b5cd199e0c900253f2d2ddf2e3491c27bc0a8"},{"version":"becd081df112726ab94c1ca1c05d6a59268fe0dabf7ad076d16ea851bf99e8fb","signature":"6039d94241358544e8d62a3a0ba90752a9973b3b2b422c187e2bcf7256fcda2e"}],"options":{"composite":true,"declaration":true,"declarationMap":true,"module":1,"noUnusedLocals":true,"noUnusedParameters":true,"strict":true,"target":1},"referencedMap":[],"exportedModulesMap":[],"semanticDiagnosticsPerFile":[6,7,1,3,2,5,4],"latestChangedDtsFile":"./text.d.ts"},"version":"4.8.4"} \ No newline at end of file diff --git a/e2e/test/typescript-project-references/stryker.conf.json b/e2e/test/typescript-project-references/stryker.conf.json new file mode 100644 index 0000000000..58b5d0879e --- /dev/null +++ b/e2e/test/typescript-project-references/stryker.conf.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "packageManager": "npm", + "disableTypeChecks": true, + "testRunner": "mocha", + "concurrency": 1, + "coverageAnalysis": "perTest", + "reporters": ["json", "html", "progress", "clear-text"], + "checkers": ["typescript"], + "tsconfigFile": "src/core/tsconfig.json", + "fileLogLevel": "warn", + "buildCommand": "npm run build", + "plugins": [ + "@stryker-mutator/mocha-runner", + "@stryker-mutator/typescript-checker" + ] +} diff --git a/e2e/test/typescript-project-references/test/job.spec.ts b/e2e/test/typescript-project-references/test/job.spec.ts new file mode 100644 index 0000000000..d5d1b4d0f8 --- /dev/null +++ b/e2e/test/typescript-project-references/test/job.spec.ts @@ -0,0 +1,12 @@ +import { expect } from 'chai'; +import {start} from '../src/core/job'; + +describe(start.name, () => { + it('should format a correct message', () => { + // Act + const result = start(); + + // Assert + expect(result).eq("Starting job"); + }); +}); diff --git a/e2e/test/typescript-project-references/tsconfig.json b/e2e/test/typescript-project-references/tsconfig.json new file mode 100644 index 0000000000..0fd2bab04d --- /dev/null +++ b/e2e/test/typescript-project-references/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": ["src/core", "src/utils", "test"] +} diff --git a/e2e/test/typescript-project-references/tsconfig.settings.json b/e2e/test/typescript-project-references/tsconfig.settings.json new file mode 100644 index 0000000000..3bb8b4a435 --- /dev/null +++ b/e2e/test/typescript-project-references/tsconfig.settings.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es5", + "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/e2e/test/typescript-project-references/verify/package.json b/e2e/test/typescript-project-references/verify/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/e2e/test/typescript-project-references/verify/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/e2e/test/typescript-project-references/verify/verify.js b/e2e/test/typescript-project-references/verify/verify.js new file mode 100644 index 0000000000..fca3cfce0f --- /dev/null +++ b/e2e/test/typescript-project-references/verify/verify.js @@ -0,0 +1,7 @@ +import { expectMetricsJsonToMatchSnapshot } from '../../../helpers.js'; + +describe('Verify stryker has ran correctly', () => { + it('should report correct score', async () => { + await expectMetricsJsonToMatchSnapshot(); + }); +}); diff --git a/e2e/test/typescript-project-references/verify/verify.js.snap b/e2e/test/typescript-project-references/verify/verify.js.snap new file mode 100644 index 0000000000..11235cc953 --- /dev/null +++ b/e2e/test/typescript-project-references/verify/verify.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Verify stryker has ran correctly should report correct score 1`] = ` +Object { + "compileErrors": 3, + "ignored": 0, + "killed": 1, + "mutationScore": 33.33333333333333, + "mutationScoreBasedOnCoveredCode": 33.33333333333333, + "noCoverage": 0, + "runtimeErrors": 0, + "survived": 2, + "timeout": 0, + "totalCovered": 3, + "totalDetected": 1, + "totalInvalid": 3, + "totalMutants": 6, + "totalUndetected": 2, + "totalValid": 3, +} +`; diff --git a/packages/typescript-checker/.vscode/launch.json b/packages/typescript-checker/.vscode/launch.json index 427d8e5417..ea87390aee 100644 --- a/packages/typescript-checker/.vscode/launch.json +++ b/packages/typescript-checker/.vscode/launch.json @@ -4,7 +4,24 @@ { "type": "node", "request": "launch", - "name": "💙 Unit / Integration tests", + "name": "💙 Unit tests", + "program": "${workspaceRoot}/../../node_modules/mocha/bin/_mocha", + "internalConsoleOptions": "openOnSessionStart", + "outFiles": [ + "${workspaceRoot}/dist/**/*.js" + ], + "skipFiles": [ + "/**" + ], + "args": [ + "--no-timeout", + "dist/test/unit/**/*.js", + ] + }, + { + "type": "node", + "request": "launch", + "name": "💙 Integration tests", "program": "${workspaceRoot}/../../node_modules/mocha/bin/_mocha", "internalConsoleOptions": "openOnSessionStart", "outFiles": [ @@ -15,7 +32,6 @@ ], "args": [ "--no-timeout", - "dist/test/unit/**/*.js", "dist/test/integration/**/*.js" ] } 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..0812ab8820 --- /dev/null +++ b/packages/typescript-checker/schema/typescript-checker-options.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "TypescriptCheckerPluginOptions", + "type": "object", + "additionalProperties": false, + "properties": { + "typescriptChecker": { + "description": "Configuration for @stryker-mutator/typescript-checker", + "title": "TypescriptCheckerOptions", + "additionalProperties": false, + "type": "object", + "default": {}, + "properties": { + "prioritizePerformanceOverAccuracy": { + "description": "Configures the performance of the TypescriptChecker. Setting this to false results in a slower, but more accurate result.", + "type": "boolean", + "default": true + } + } + } + } +} diff --git a/packages/typescript-checker/src/fs/hybrid-file-system.ts b/packages/typescript-checker/src/fs/hybrid-file-system.ts index 39d94862f1..b4a3bfd2d9 100644 --- a/packages/typescript-checker/src/fs/hybrid-file-system.ts +++ b/packages/typescript-checker/src/fs/hybrid-file-system.ts @@ -1,5 +1,4 @@ import ts from 'typescript'; -import { Mutant } from '@stryker-mutator/api/core'; import { Logger } from '@stryker-mutator/api/logging'; import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; @@ -16,7 +15,6 @@ import { ScriptFile } from './script-file.js'; */ export class HybridFileSystem { private readonly files = new Map(); - private mutatedFile: ScriptFile | undefined; public static inject = tokens(commonTokens.logger); constructor(private readonly log: Logger) {} @@ -32,19 +30,6 @@ export class HybridFileSystem { } } - public mutate(mutant: Pick): void { - const fileName = toPosixFileName(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): void { const file = this.getFile(fileName); if (file) { diff --git a/packages/typescript-checker/src/fs/script-file.ts b/packages/typescript-checker/src/fs/script-file.ts index b9abf87943..675d0912ad 100644 --- a/packages/typescript-checker/src/fs/script-file.ts +++ b/packages/typescript-checker/src/fs/script-file.ts @@ -45,7 +45,7 @@ export class ScriptFile { } } - private touch() { + private touch(): void { this.modifiedTime = new Date(); this.watcher?.(this.fileName, ts.FileWatcherEventKind.Changed); } diff --git a/packages/typescript-checker/src/grouping/create-groups.ts b/packages/typescript-checker/src/grouping/create-groups.ts new file mode 100644 index 0000000000..7f5cc3900a --- /dev/null +++ b/packages/typescript-checker/src/grouping/create-groups.ts @@ -0,0 +1,89 @@ +import { Mutant } from '@stryker-mutator/api/src/core/index.js'; + +import { toPosixFileName } from '../tsconfig-helpers.js'; + +import { TSFileNode } from './ts-file-node.js'; + +/** + * To speed up the type-checking we want to check multiple mutants at once. + * When multiple mutants in different files don't have overlap in affected files (or have small overlap), we can type-check them simultaneously. + * These mutants who can be tested at the same time are called a group. + * Therefore, the return type is an array of arrays, in other words: an array of groups. + * + * @param mutants All the mutants of the test project. + * @param nodes A graph representation of the test project. + * + * @example + * Let's assume we got the following dependencies in files of a project, and in every file is one mutant. + * + * ======== + * = A.ts = + * ======== + * / \ + * ======== ======== + * = B.ts = = C.ts = + * ======== ======== + * \ + * ======== + * = D.ts = + * ======== + * + * A imports B and C + * C imports D + * + * In this example, we can type-check B and D simultaneously. + * This is because these files can't throw errors in each other. + * If we type check them, let's say B reports an error. + * We know that the mutant in B created the type error. + * If we type check B and D at the same time, it is possible that an error shows up in A. + * When this happens, we go down the dependency graph and individually test the mutants in that group. + * + * In this function, we create groups of mutants who can be tested at the same time. + */ +export function createGroups(mutants: Mutant[], nodes: Map): string[][] { + const groups: string[][] = []; + const mutantsToGroup = new Set(mutants); + + while (mutantsToGroup.size) { + const group: string[] = []; + const groupNodes = new Set(); + const nodesToIgnore = new Set(); + + for (const currentMutant of mutantsToGroup) { + const currentNode = findNode(currentMutant.fileName, nodes); + if (!nodesToIgnore.has(currentNode) && !parentsHaveOverlapWith(currentNode, groupNodes)) { + group.push(currentMutant.id); + groupNodes.add(currentNode); + mutantsToGroup.delete(currentMutant); + addRangeOfNodesToSet(nodesToIgnore, currentNode.getAllParentReferencesIncludingSelf()); + } + } + groups.push(group); + } + + return groups; +} + +function addRangeOfNodesToSet(nodes: Set, nodesToAdd: Iterable) { + for (const parent of nodesToAdd) { + nodes.add(parent); + } +} + +function findNode(fileName: string, nodes: Map) { + const node = nodes.get(toPosixFileName(fileName)); + if (node == null) { + throw new Error(`Node not in graph: ${fileName}`); + } + return node; +} + +function parentsHaveOverlapWith(currentNode: TSFileNode, groupNodes: Set) { + for (const parentNode of currentNode.getAllParentReferencesIncludingSelf()) { + if (groupNodes.has(parentNode)) { + return true; + } + } + + return false; +} diff --git a/packages/typescript-checker/src/grouping/ts-file-node.ts b/packages/typescript-checker/src/grouping/ts-file-node.ts new file mode 100644 index 0000000000..eebe180405 --- /dev/null +++ b/packages/typescript-checker/src/grouping/ts-file-node.ts @@ -0,0 +1,41 @@ +import { Mutant } from '@stryker-mutator/api/src/core'; + +import { toPosixFileName } from '../tsconfig-helpers.js'; + +// This class exist so we can have a two way dependency graph. +// the two way dependency graph is used to search for mutants related to typescript errors +export class TSFileNode { + constructor(public fileName: string, public parents: TSFileNode[], public children: TSFileNode[]) {} + + public getAllParentReferencesIncludingSelf(allParentReferences: Set = new Set()): Set { + allParentReferences.add(this); + this.parents.forEach((parent) => { + if (!allParentReferences.has(parent)) { + parent.getAllParentReferencesIncludingSelf(allParentReferences); + } + }); + return allParentReferences; + } + + public getAllChildReferencesIncludingSelf(allChildReferences: Set = new Set()): Set { + allChildReferences.add(this); + this.children.forEach((child) => { + if (!allChildReferences.has(child)) { + child.getAllChildReferencesIncludingSelf(allChildReferences); + } + }); + return allChildReferences; + } + + public getMutantsWithReferenceToChildrenOrSelf(mutants: Mutant[], nodesChecked: string[] = []): Mutant[] { + if (nodesChecked.includes(this.fileName)) { + return []; + } + + nodesChecked.push(this.fileName); + + const relatedMutants = mutants.filter((m) => toPosixFileName(m.fileName) == this.fileName); + const childResult = this.children.flatMap((c) => c.getMutantsWithReferenceToChildrenOrSelf(mutants, nodesChecked)); + return [...relatedMutants, ...childResult]; + } +} diff --git a/packages/typescript-checker/src/index.ts b/packages/typescript-checker/src/index.ts index aac48b8b27..12ce986969 100644 --- a/packages/typescript-checker/src/index.ts +++ b/packages/typescript-checker/src/index.ts @@ -1,3 +1,5 @@ +import fs from 'fs'; + import { PluginKind, declareFactoryPlugin } from '@stryker-mutator/api/plugin'; import { create } from './typescript-checker.js'; @@ -5,3 +7,7 @@ import { create } from './typescript-checker.js'; export const strykerPlugins = [declareFactoryPlugin(PluginKind.Checker, 'typescript', create)]; export const createTypescriptChecker = create; + +export const strykerValidationSchema: typeof import('../schema/typescript-checker-options.json') = JSON.parse( + fs.readFileSync(new URL('../schema/typescript-checker-options.json', import.meta.url), 'utf-8') +); diff --git a/packages/typescript-checker/src/plugin-tokens.ts b/packages/typescript-checker/src/plugin-tokens.ts index ecb64f9a01..7e49d56de0 100644 --- a/packages/typescript-checker/src/plugin-tokens.ts +++ b/packages/typescript-checker/src/plugin-tokens.ts @@ -1 +1,2 @@ export const fs = 'fs'; +export const tsCompiler = 'tsCompiler'; diff --git a/packages/typescript-checker/src/tsconfig-helpers.ts b/packages/typescript-checker/src/tsconfig-helpers.ts index 7bb5b9c8df..35384dc8aa 100644 --- a/packages/typescript-checker/src/tsconfig-helpers.ts +++ b/packages/typescript-checker/src/tsconfig-helpers.ts @@ -15,21 +15,21 @@ const COMPILER_OPTIONS_OVERRIDES: Readonly> = Object 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 + tsBuildInfoFile: undefined, composite: false, - declaration: false, - declarationMap: 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, + declarationMap: true, + declaration: true, }); export function guardTSVersion(): void { 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}`); + throw new Error(`@stryker-mutator/typescript-checker only supports typescript@3.6 or higher. Found typescript@${ts.version}`); } } @@ -64,6 +64,15 @@ export function overrideOptions(parsedConfig: { config?: any }, useBuildMode: bo delete compilerOptions.declarationDir; } + if (useBuildMode) { + // Remove the options to place declarations files in different locations to decrease the complexity of searching the source file in the TypescriptCompiler class. + delete compilerOptions.inlineSourceMap; + delete compilerOptions.inlineSources; + delete compilerOptions.mapRoute; + delete compilerOptions.sourceRoot; + delete compilerOptions.outFile; + } + return JSON.stringify({ ...parsedConfig.config, compilerOptions, @@ -92,3 +101,14 @@ export function retrieveReferencedProjects(parsedConfig: { config?: any }, fromD export function toPosixFileName(fileName: string): string { return fileName.replace(/\\/g, '/'); } + +/** + * Find source file in declaration file + * @param content The content of the declaration file + * @returns URL of the source file or undefined if not found + */ +const findSourceMapRegex = /\/\/# sourceMappingURL=(.+)$/; +export function getSourceMappingURL(content: string): string | undefined { + findSourceMapRegex.lastIndex = 0; + return findSourceMapRegex.exec(content)?.[1]; +} diff --git a/packages/typescript-checker/src/typescript-checker-options-with-stryker-options.ts b/packages/typescript-checker/src/typescript-checker-options-with-stryker-options.ts new file mode 100644 index 0000000000..2384f00a51 --- /dev/null +++ b/packages/typescript-checker/src/typescript-checker-options-with-stryker-options.ts @@ -0,0 +1,5 @@ +import { StrykerOptions } from '@stryker-mutator/api/core'; + +import { TypescriptCheckerPluginOptions } from '../src-generated/typescript-checker-options'; + +export interface TypescriptCheckerOptionsWithStrykerOptions extends TypescriptCheckerPluginOptions, StrykerOptions {} diff --git a/packages/typescript-checker/src/typescript-checker.ts b/packages/typescript-checker/src/typescript-checker.ts index 9732364664..187a1b95aa 100644 --- a/packages/typescript-checker/src/typescript-checker.ts +++ b/packages/typescript-checker/src/typescript-checker.ts @@ -1,24 +1,19 @@ import { EOL } from 'os'; -import path from 'path'; import ts from 'typescript'; import { Checker, CheckResult, CheckStatus } from '@stryker-mutator/api/check'; import { tokens, commonTokens, PluginContext, Injector, Scope } from '@stryker-mutator/api/plugin'; import { Logger, LoggerFactoryMethod } from '@stryker-mutator/api/logging'; -import { Task, propertyPath } from '@stryker-mutator/util'; import { Mutant, StrykerOptions } from '@stryker-mutator/api/core'; +import { split, strykerReportBugUrl } from '@stryker-mutator/util'; -import { HybridFileSystem } from './fs/index.js'; -import { determineBuildModeEnabled, overrideOptions, retrieveReferencedProjects, guardTSVersion, toPosixFileName } from './tsconfig-helpers.js'; import * as pluginTokens from './plugin-tokens.js'; - -const diagnosticsHost: ts.FormatDiagnosticsHost = { - getCanonicalFileName: (fileName) => fileName, - getCurrentDirectory: process.cwd, - getNewLine: () => EOL, -}; - -const FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE = 6032; +import { TypescriptCompiler } from './typescript-compiler.js'; +import { createGroups } from './grouping/create-groups.js'; +import { toPosixFileName } from './tsconfig-helpers.js'; +import { TSFileNode } from './grouping/ts-file-node.js'; +import { TypescriptCheckerOptionsWithStrykerOptions } from './typescript-checker-options-with-stryker-options.js'; +import { HybridFileSystem } from './fs/hybrid-file-system.js'; typescriptCheckerLoggerFactory.inject = tokens(commonTokens.getLogger, commonTokens.target); // eslint-disable-next-line @typescript-eslint/ban-types @@ -33,6 +28,7 @@ export function create(injector: Injector): TypescriptChecker { return injector .provideFactory(commonTokens.logger, typescriptCheckerLoggerFactory, Scope.Transient) .provideClass(pluginTokens.fs, HybridFileSystem) + .provideClass(pluginTokens.tsCompiler, TypescriptCompiler) .injectClass(TypescriptChecker); } @@ -40,169 +36,145 @@ export function create(injector: Injector): TypescriptChecker { * An in-memory type checker implementation which validates type errors of mutants. */ export class TypescriptChecker implements Checker { - private currentTask = new 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, pluginTokens.fs); - private readonly tsconfigFile: string; + public static inject = tokens(commonTokens.logger, commonTokens.options, pluginTokens.tsCompiler); + private readonly options: TypescriptCheckerOptionsWithStrykerOptions; - constructor(private readonly logger: Logger, options: StrykerOptions, private readonly fs: HybridFileSystem) { - this.tsconfigFile = toPosixFileName(options.tsconfigFile); - this.allTSConfigFiles = new Set([path.resolve(this.tsconfigFile)]); + constructor(private readonly logger: Logger, options: StrykerOptions, private readonly tsCompiler: TypescriptCompiler) { + this.options = options as TypescriptCheckerOptionsWithStrykerOptions; } /** * 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: (filePath: string, callback: ts.FileWatcherCallback) => { - this.fs.watchFile(filePath, callback); - return { - close: () => { - delete this.fs.getFile(filePath)!.watcher; - }, - }; - }, - writeFile: (filePath, data) => { - this.fs.writeFile(filePath, 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 { - // eslint-disable-next-line @typescript-eslint/no-empty-function - 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}`); - } - } + const errors = await this.tsCompiler.init(); - 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()('tsconfigFile')}"` - ); + if (errors.length) { + throw new Error(`Typescript error(s) found in dry run compilation: ${this.createErrorText(errors)}`); } } /** * 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 + * @param mutants The mutants to check */ public async check(mutants: Mutant[]): Promise> { - const mutant = mutants[0]; + const result: Record = Object.fromEntries(mutants.map((mutant) => [mutant.id, { status: CheckStatus.Passed }])); - if (this.fs.existsInMemory(mutant.fileName)) { - this.clearCheckState(); - this.fs.mutate(mutant); + // Check if this is the group with unrelated files and return check status passed if so + if (!this.tsCompiler.nodes.get(toPosixFileName(mutants[0].fileName))) { + return result; + } - return { - [mutant.id]: await this.currentTask.promise, - }; - } else { - // We allow people to mutate files that are not included in this ts project - return { - [mutant.id]: { - status: CheckStatus.Passed, - }, - }; + const mutantErrorRelationMap = await this.checkErrors(mutants, {}, this.tsCompiler.nodes); + for (const [id, errors] of Object.entries(mutantErrorRelationMap)) { + result[id] = { status: CheckStatus.CompileError, reason: this.createErrorText(errors) }; } + + return result; } /** - * 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 + * Creates groups of the mutants. + * These groups will get send to the check method. + * @param mutants All the mutants to group. */ - 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 + public async group(mutants: Mutant[]): Promise { + if (!this.options.typescriptChecker.prioritizePerformanceOverAccuracy) { + return mutants.map((m) => [m.id]); + } + const nodes = this.tsCompiler.nodes; + const [mutantsOutsideProject, mutantsInProject] = split(mutants, (m) => nodes.get(toPosixFileName(m.fileName)) == null); + + const groups = createGroups(mutantsInProject, nodes); + if (mutantsOutsideProject.length) { + return [mutantsOutsideProject.map((m) => m.id), ...groups]; } else { - for (const referencedProject of retrieveReferencedProjects(parsedConfig, path.dirname(fileName))) { - this.allTSConfigFiles.add(referencedProject); - } - return overrideOptions(parsedConfig, buildModeEnabled); + return groups; } } - /** - * 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, - }); + private async checkErrors( + mutants: Mutant[], + errorsMap: Record, + nodes: Map + ): Promise> { + const errors = await this.tsCompiler.check(mutants); + const mutantsThatCouldNotBeTestedInGroups = new Set(); + + //If there is only a single mutant the error has to originate from the single mutant + if (errors.length && mutants.length === 1) { + errorsMap[mutants[0].id] = errors; + return errorsMap; } - this.currentTask.resolve({ status: CheckStatus.Passed }); - } - /** - * Clear state between checks - */ - private clearCheckState() { - while (this.currentErrors.pop()) { - // Idle + for (const error of errors) { + if (!error.file?.fileName) { + throw new Error( + `Typescript error: '${ + error.messageText + }' was reported without a corresponding file. This shouldn't happen. Please open an issue using this link: ${strykerReportBugUrl( + `[BUG]: TypeScript checker reports compile error without a corresponding file: ${error.messageText}` + )}` + ); + } + const nodeErrorWasThrownIn = nodes.get(error.file?.fileName); + if (!nodeErrorWasThrownIn) { + throw new Error( + `Typescript error: '${error.messageText}' was reported in an unrelated file (${ + error.file.fileName + }). This file is not part of your project, or referenced from your project. This shouldn't happen, please open an issue using this link: ${strykerReportBugUrl( + `[BUG]: TypeScript checker reports compile error in an unrelated file: ${error.messageText}` + )}` + ); + } + const mutantsRelatedToError = nodeErrorWasThrownIn.getMutantsWithReferenceToChildrenOrSelf(mutants); + + if (mutantsRelatedToError.length === 0) { + // In rare cases there are no mutants related to the typescript error + // Having to test all mutants individually to know which mutant thrown the error + for (const mutant of mutants) { + mutantsThatCouldNotBeTestedInGroups.add(mutant); + } + } else if (mutantsRelatedToError.length === 1) { + // There is only one mutant related to the typescript error so we can add it to the errorsRelatedToMutant + if (errorsMap[mutantsRelatedToError[0].id]) { + errorsMap[mutantsRelatedToError[0].id].push(error); + } else { + errorsMap[mutantsRelatedToError[0].id] = [error]; + } + } else { + // If there are more than one mutants related to the error we should check them individually + for (const mutant of mutantsRelatedToError) { + mutantsThatCouldNotBeTestedInGroups.add(mutant); + } + } + } + + if (mutantsThatCouldNotBeTestedInGroups.size) { + //Because at this point the filesystem contains all the mutants from the group we need to reset back + //to the original state of the files to make it possible to test the first mutant + //if we wouldn't do this the first mutant would not be noticed by the compiler because it was already in the filesystem + await this.tsCompiler.check([]); + } + for (const mutant of mutantsThatCouldNotBeTestedInGroups) { + if (errorsMap[mutant.id]) continue; + await this.checkErrors([mutant], errorsMap, nodes); } - this.currentTask = new Task(); + + return errorsMap; + } + + private createErrorText(errors: ts.Diagnostic[]): string { + return ts.formatDiagnostics(errors, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: process.cwd, + getNewLine: () => EOL, + }); } - private readonly logDiagnostic = (label: string) => { - return (d: ts.Diagnostic) => { - this.logger.trace(`${label} ${ts.formatDiagnostics([d], diagnosticsHost)}`); - }; - }; } diff --git a/packages/typescript-checker/src/typescript-compiler.ts b/packages/typescript-checker/src/typescript-compiler.ts new file mode 100644 index 0000000000..87671d0153 --- /dev/null +++ b/packages/typescript-checker/src/typescript-compiler.ts @@ -0,0 +1,271 @@ +import path from 'path'; + +import ts from 'typescript'; +import { propertyPath, Task } from '@stryker-mutator/util'; +import { Mutant, StrykerOptions } from '@stryker-mutator/api/core'; +import { Logger } from '@stryker-mutator/api/logging'; + +import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; + +import { HybridFileSystem } from './fs/index.js'; +import { + determineBuildModeEnabled, + getSourceMappingURL, + guardTSVersion, + overrideOptions, + retrieveReferencedProjects, + toPosixFileName, +} from './tsconfig-helpers.js'; +import { TSFileNode } from './grouping/ts-file-node.js'; +import * as pluginTokens from './plugin-tokens.js'; + +export interface ITypescriptCompiler { + init(): Promise; + check(mutants: Mutant[]): Promise; +} + +export interface IFileRelationCreator { + get nodes(): Map; +} + +export type SourceFiles = Map< + string, + { + fileName: string; + imports: Set; + } +>; +const FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE = 6032; + +export class TypescriptCompiler implements ITypescriptCompiler, IFileRelationCreator { + public static inject = tokens(commonTokens.logger, commonTokens.options, pluginTokens.fs); + + private readonly allTSConfigFiles: Set; + private readonly tsconfigFile: string; + private currentTask = new Task(); + private currentErrors: ts.Diagnostic[] = []; + private readonly sourceFiles: SourceFiles = new Map(); + private readonly _nodes = new Map(); + private lastMutants: Mutant[] = []; + + constructor(private readonly log: Logger, private readonly options: StrykerOptions, private readonly fs: HybridFileSystem) { + this.tsconfigFile = toPosixFileName(this.options.tsconfigFile); + this.allTSConfigFiles = new Set([path.resolve(this.tsconfigFile)]); + } + + public async init(): Promise { + guardTSVersion(); + this.guardTSConfigFileExists(); + const buildModeEnabled = determineBuildModeEnabled(this.tsconfigFile); + + const host = ts.createSolutionBuilderWithWatchHost( + { + ...ts.sys, + readFile: (fileName) => { + if (this.fileNameIsBuildInfo(fileName)) { + return undefined; + } + const content = this.fs.getFile(fileName)?.content; + if (content && this.allTSConfigFiles.has(path.resolve(fileName))) { + return this.adjustTSConfigFile(fileName, content, buildModeEnabled); + } + return content; + }, + fileExists: (fileName: string) => { + // We want to ignore the buildinfo files. With them the compiler skips the program part we want to use. + if (this.fileNameIsBuildInfo(fileName)) { + return false; + } + return ts.sys.fileExists(fileName); + }, + getModifiedTime: (fileName: string) => { + if (this.fileNameIsBuildInfo(fileName)) { + return undefined; + } + return this.fs.getFile(fileName)?.modifiedTime; + }, + watchFile: (fileName: string, callback: ts.FileWatcherCallback) => { + const file = this.fs.getFile(fileName); + + if (file) { + file.watcher = callback; + } + + return { + close: () => { + delete this.fs.getFile(fileName)!.watcher; + }, + }; + }, + writeFile: (fileName, data) => { + this.fs.writeFile(fileName, data); + }, + watchDirectory: (): ts.FileWatcher => { + // this is used to see if new files are added to a directory. Can safely be ignored for mutation testing. + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + close() {}, + }; + }, + }, + (...args) => { + const program = ts.createEmitAndSemanticDiagnosticsBuilderProgram(...args); + if (this._nodes.size) { + return program; + } + program + .getSourceFiles() + .filter(filterDependency) + .forEach((file) => { + this.sourceFiles.set(file.fileName, { + fileName: file.fileName, + imports: new Set( + program + .getAllDependencies(file) + .filter((importFile) => !importFile.includes('/node_modules/') && file.fileName !== importFile) + .flatMap((importFile) => this.resolveTSInputFile(importFile)) + ), + }); + }); + + function filterDependency(file: ts.SourceFile) { + if (file.fileName.endsWith('.d.ts') || file.fileName.includes('node_modules')) { + return false; + } + + return true; + } + + return program; + }, + (error) => { + this.currentErrors.push(error); + }, + (status) => { + this.log.debug(status.messageText.toString()); + }, + (summary) => { + summary.code !== FILE_CHANGE_DETECTED_DIAGNOSTIC_CODE && this.currentTask.resolve(); + } + ); + + const compiler = ts.createSolutionBuilderWithWatch(host, [this.tsconfigFile], {}); + compiler.build(); + return await this.check([]); + } + + public async check(mutants: Mutant[]): Promise { + this.lastMutants.forEach((mutant) => { + const file = this.fs.getFile(mutant.fileName); + file!.resetMutant(); + }); + mutants.forEach((mutant) => { + const file = this.fs.getFile(mutant.fileName); + file!.mutate(mutant); + }); + await this.currentTask.promise; + const errors = this.currentErrors; + this.currentTask = new Task(); + this.currentErrors = []; + this.lastMutants = mutants; + return errors; + } + + public get nodes(): Map { + if (!this._nodes.size) { + // create nodes + for (const [fileName] of this.sourceFiles) { + const node = new TSFileNode(fileName, [], []); + this._nodes.set(fileName, node); + } + + // set children + for (const [fileName, file] of this.sourceFiles) { + const node = this._nodes.get(fileName); + if (node == null) { + throw new Error( + `Node for file '${fileName}' could not be found. This should not happen. This shouldn't happen, please open an issue on the stryker-js github` + ); + } + + const importFileNames = [...file.imports]; + node.children = importFileNames.map((importName) => this._nodes.get(importName)!).filter((n) => n != undefined); + } + + // set parents + for (const [, node] of this._nodes) { + node.parents = []; + for (const [, n] of this._nodes) { + if (n.children.includes(node)) { + node.parents.push(n); + } + } + } + } + + return this._nodes; + } + + /** + * Resolves TS input file based on a dependency of a input file + * @param dependencyFileName The dependency file name. With TS project references this can be a declaration file + * @returns TS source file if found (fallbacks to input filename) + */ + private resolveTSInputFile(dependencyFileName: string): string { + if (!dependencyFileName.endsWith('.d.ts')) { + return dependencyFileName; + } + + const file = this.fs.getFile(dependencyFileName); + if (!file) { + throw new Error(`Could not find ${dependencyFileName}`); + } + + const sourceMappingURL = getSourceMappingURL(file.content); + if (!sourceMappingURL) { + return dependencyFileName; + } + + const sourceMapFileName = path.resolve(path.dirname(dependencyFileName), sourceMappingURL); + const sourceMap = this.fs.getFile(sourceMapFileName); + if (!sourceMap) { + this.log.warn(`Could not find sourcemap ${sourceMapFileName}`); + return dependencyFileName; + } + + const sources: string[] | undefined = JSON.parse(sourceMap.content).sources; + + if (sources?.length === 1) { + const sourcePath = sources[0]; + return toPosixFileName(path.resolve(path.dirname(sourceMapFileName), sourcePath)); + } + + return dependencyFileName; + } + + 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, path.dirname(fileName))) { + this.allTSConfigFiles.add(referencedProject); + } + return overrideOptions(parsedConfig, buildModeEnabled); + } + } + + 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()('tsconfigFile')}"` + ); + } + } + + private fileNameIsBuildInfo(fileName: string): boolean { + return fileName.endsWith('.tsbuildinfo'); + } +} diff --git a/packages/typescript-checker/test/helpers.ts b/packages/typescript-checker/test/helpers.ts new file mode 100644 index 0000000000..989f5c9ba3 --- /dev/null +++ b/packages/typescript-checker/test/helpers.ts @@ -0,0 +1,13 @@ +import ts from 'typescript'; + +export function createTSDiagnostic(overrides?: Partial): ts.Diagnostic { + return { + category: ts.DiagnosticCategory.Error, + code: 42, + file: undefined, + length: undefined, + messageText: 'foo', + start: undefined, + ...overrides, + }; +} diff --git a/packages/typescript-checker/test/integration/project-references.it.spec.ts b/packages/typescript-checker/test/integration/project-references.it.spec.ts index f0469ab89b..3ad68b3806 100644 --- a/packages/typescript-checker/test/integration/project-references.it.spec.ts +++ b/packages/typescript-checker/test/integration/project-references.it.spec.ts @@ -1,6 +1,5 @@ import path from 'path'; import fs from 'fs'; -import os from 'os'; import { fileURLToPath } from 'url'; @@ -11,6 +10,7 @@ import { testInjector, factory } from '@stryker-mutator/test-helpers'; import { createTypescriptChecker } from '../../src/index.js'; import { TypescriptChecker } from '../../src/typescript-checker.js'; +import { TypescriptCheckerOptionsWithStrykerOptions } from '../../src/typescript-checker-options-with-stryker-options.js'; const resolveTestResource = path.resolve.bind( path, @@ -26,37 +26,48 @@ describe('Typescript checker on a project with project references', () => { let sut: TypescriptChecker; beforeEach(() => { + (testInjector.options as TypescriptCheckerOptionsWithStrykerOptions).typescriptChecker = { prioritizePerformanceOverAccuracy: true }; testInjector.options.tsconfigFile = resolveTestResource('tsconfig.root.json'); sut = testInjector.injector.injectFunction(createTypescriptChecker); return sut.init(); }); - it('should not write output to disk', () => { + it('should not write output to disk', async () => { 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', 'mutId'); - const expectedResult: Record = { mutId: { status: CheckStatus.Passed } }; + const mutant = createMutant('job.ts', 'Starting job', 'stryker was here'); + const expectedResult: Record = { [mutant.id]: { 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', 'mutId'); - const expectedResult: Record = { mutId: { status: CheckStatus.Passed } }; + const mutant = createMutant('job.ts', 'toUpperCase(logText)', 'toUpperCase("")'); + const expectedResult: Record = { [mutant.id]: { status: CheckStatus.Passed } }; const actual = await sut.check([mutant]); expect(actual).deep.eq(expectedResult); }); + + it('should create multiple groups if reference between project', async () => { + const mutantInSourceProject = createMutant('job.ts', 'Starting job', '', '42'); + const mutantInProjectWithReference = createMutant('text.ts', 'toUpperCase()', 'toLowerCase()', '43'); + const mutantOutsideOfReference = createMutant('math.ts', 'array.length', '1', '44'); + const result = await sut.group([mutantInSourceProject, mutantInProjectWithReference, mutantOutsideOfReference]); + expect(result).to.have.lengthOf(2); + }); }); 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'), + ['index.ts']: fs.readFileSync(resolveTestResource('src', 'index.ts'), 'utf8'), + ['job.ts']: fs.readFileSync(resolveTestResource('src', 'job.ts'), 'utf8'), + ['math.ts']: fs.readFileSync(resolveTestResource('utils', 'math.ts'), 'utf8'), + ['text.ts']: fs.readFileSync(resolveTestResource('utils', 'text.ts'), 'utf8'), }); -function createMutant(fileName: 'src/todo.ts' | 'test/todo.spec.ts', findText: string, replacement: string, id = '42', offset = 0): Mutant { - const lines = fileContents[fileName].split(os.EOL); +function createMutant(fileName: 'index.ts' | 'job.ts' | 'math.ts' | 'text.ts', findText: string, replacement: string, id = '42', offset = 0): Mutant { + const lines = fileContents[fileName].split('\n'); // todo fix this \n const lineNumber = lines.findIndex((l) => l.includes(findText)); if (lineNumber === -1) { throw new Error(`Cannot find ${findText} in ${fileName}`); diff --git a/packages/typescript-checker/test/integration/project-with-ts-buildinfo.it.spec.ts b/packages/typescript-checker/test/integration/project-with-ts-buildinfo.it.spec.ts new file mode 100644 index 0000000000..86066a6228 --- /dev/null +++ b/packages/typescript-checker/test/integration/project-with-ts-buildinfo.it.spec.ts @@ -0,0 +1,56 @@ +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +import { fileURLToPath } from 'url'; + +import { expect } from 'chai'; +import { Location, Mutant } from '@stryker-mutator/api/core'; +import { testInjector, factory } from '@stryker-mutator/test-helpers'; + +import { createTypescriptChecker } from '../../src/index.js'; +import { TypescriptCheckerOptionsWithStrykerOptions } from '../../src/typescript-checker-options-with-stryker-options.js'; + +const resolveTestResource = path.resolve.bind( + path, + path.dirname(fileURLToPath(import.meta.url)), + '..' /* integration */, + '..' /* test */, + '..' /* dist */, + 'testResources', + 'project-with-ts-buildinfo' +) as unknown as typeof path.resolve; + +describe('project-with-ts-buildinfo', () => { + it('should load project on init', async () => { + (testInjector.options as TypescriptCheckerOptionsWithStrykerOptions).typescriptChecker = { prioritizePerformanceOverAccuracy: true }; + testInjector.options.tsconfigFile = resolveTestResource('tsconfig.json'); + const sut = testInjector.injector.injectFunction(createTypescriptChecker); + const group = await sut.group([createMutant('src/index.ts', '', '')]); + expect(group).lengthOf(1); + }); +}); + +const fileContents = Object.freeze({ + ['src/index.ts']: fs.readFileSync(resolveTestResource('src', 'index.ts'), 'utf8'), +}); + +function createMutant(fileName: 'src/index.ts', findText: string, replacement: string, id = '42', offset = 0): Mutant { + const lines = fileContents[fileName].split(os.EOL); + const lineNumber = lines.findIndex((l) => l.includes(findText)); + if (lineNumber === -1) { + throw new Error(`Cannot find ${findText} in ${fileName}`); + } + const textColumn = lines[lineNumber].indexOf(findText); + const location: Location = { + start: { line: lineNumber, column: textColumn + offset }, + end: { line: lineNumber, column: textColumn + findText.length }, + }; + return factory.mutant({ + id, + fileName: resolveTestResource('src', fileName), + mutatorName: 'foo-mutator', + location, + 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 index b3f372f84d..6ecd9f1aad 100644 --- a/packages/typescript-checker/test/integration/single-project.it.spec.ts +++ b/packages/typescript-checker/test/integration/single-project.it.spec.ts @@ -9,6 +9,7 @@ import { CheckResult, CheckStatus } from '@stryker-mutator/api/check'; import { createTypescriptChecker } from '../../src/index.js'; import { TypescriptChecker } from '../../src/typescript-checker.js'; +import { TypescriptCheckerOptionsWithStrykerOptions } from '../../src/typescript-checker-options-with-stryker-options.js'; const resolveTestResource = path.resolve.bind( path, @@ -24,6 +25,7 @@ describe('Typescript checker on a single project', () => { let sut: TypescriptChecker; beforeEach(() => { + (testInjector.options as TypescriptCheckerOptionsWithStrykerOptions).typescriptChecker = { prioritizePerformanceOverAccuracy: true }; testInjector.options.tsconfigFile = resolveTestResource('tsconfig.json'); sut = testInjector.injector.injectFunction(createTypescriptChecker); return sut.init(); @@ -92,16 +94,48 @@ describe('Typescript checker on a single project', () => { const actual = await sut.check([mutant]); expect(actual).deep.eq(expectedResult); }); + it('should be able invalidate 2 mutants that do result in a compile errors', async () => { + const mutant = createMutant('todo.ts', 'TodoList.allTodos.push(newItem)', '"This should not be a string 🙄"', 'mutId'); + const mutant2 = createMutant('counter.ts', 'return this.currentNumber;', 'return "This should not return a string 🙄"', 'mutId2'); + const actual = await sut.check([mutant, mutant2]); + assertions.expectCompileError(actual.mutId); + assertions.expectCompileError(actual.mutId2); + expect(actual.mutId.reason).has.string('todo.ts(15,9): error TS2322'); + expect(actual.mutId2.reason).has.string('counter.ts(7,5): error TS2322'); + }); + it('should be able invalidate 2 mutants that do result in a compile error in file above', async () => { + const mutant = createMutant('errorInFileAbove2Mutants/todo.ts', 'TodoList.allTodos.push(newItem)', '"This should not be a string 🙄"', 'mutId'); + const mutant2 = createMutant( + 'errorInFileAbove2Mutants/counter.ts', + 'return (this.currentNumber += numberToIncrementBy);', + 'return "This should not return a string 🙄"', + 'mutId2' + ); + const actual = await sut.check([mutant, mutant2]); + assertions.expectCompileError(actual.mutId); + assertions.expectCompileError(actual.mutId2); + expect(actual.mutId.reason).has.string('todo.ts(15,9): error TS2322'); + expect(actual.mutId2.reason).has.string('errorInFileAbove2Mutants/todo-counter.ts(7,7): error TS2322'); + }); }); const fileContents = Object.freeze({ + ['errorInFileAbove2Mutants/todo.ts']: fs.readFileSync(resolveTestResource('src', 'errorInFileAbove2Mutants', 'todo.ts'), 'utf8'), + ['errorInFileAbove2Mutants/counter.ts']: fs.readFileSync(resolveTestResource('src', 'errorInFileAbove2Mutants', 'counter.ts'), 'utf8'), ['todo.ts']: fs.readFileSync(resolveTestResource('src', 'todo.ts'), 'utf8'), + ['counter.ts']: fs.readFileSync(resolveTestResource('src', 'counter.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: 'not-type-checked.js' | 'todo.spec.ts' | 'todo.ts', + fileName: + | 'counter.ts' + | 'errorInFileAbove2Mutants/counter.ts' + | 'errorInFileAbove2Mutants/todo.ts' + | 'not-type-checked.js' + | 'todo.spec.ts' + | 'todo.ts', findText: string, replacement: string, id = '42', 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 index a64a8334e2..788453a7fe 100644 --- a/packages/typescript-checker/test/integration/typescript-checkers-errors.it.spec.ts +++ b/packages/typescript-checker/test/integration/typescript-checkers-errors.it.spec.ts @@ -6,6 +6,7 @@ import { testInjector } from '@stryker-mutator/test-helpers'; import { expect } from 'chai'; import { createTypescriptChecker } from '../../src/index.js'; +import { TypescriptCheckerOptionsWithStrykerOptions } from '../../src/typescript-checker-options-with-stryker-options.js'; const resolveTestResource = path.resolve.bind( path, @@ -19,22 +20,25 @@ const resolveTestResource = path.resolve.bind( describe('Typescript checker errors', () => { it('should reject initialization if initial compilation failed', async () => { + (testInjector.options as TypescriptCheckerOptionsWithStrykerOptions).typescriptChecker = { prioritizePerformanceOverAccuracy: true }; testInjector.options.tsconfigFile = resolveTestResource('compile-error', 'tsconfig.json'); const sut = testInjector.injector.injectFunction(createTypescriptChecker); await expect(sut.init()).rejectedWith( - 'TypeScript error(s) found in dry run compilation: testResources/errors/compile-error/add.ts(2,3): error TS2322:' + 'Typescript error(s) found in dry run compilation: testResources/errors/compile-error/add.ts(2,3): error TS2322:' ); }); it('should reject initialization if tsconfig was invalid', async () => { + (testInjector.options as TypescriptCheckerOptionsWithStrykerOptions).typescriptChecker = { prioritizePerformanceOverAccuracy: true }; testInjector.options.tsconfigFile = resolveTestResource('invalid-tsconfig', 'tsconfig.json'); const sut = testInjector.injector.injectFunction(createTypescriptChecker); await expect(sut.init()).rejectedWith( - 'TypeScript error(s) found in dry run compilation: testResources/errors/invalid-tsconfig/tsconfig.json(1,1): error TS1005:' + 'Typescript error(s) found in dry run compilation: testResources/errors/invalid-tsconfig/tsconfig.json(1,1): error TS1005:' ); }); it("should reject when tsconfig file doesn't exist", async () => { + (testInjector.options as TypescriptCheckerOptionsWithStrykerOptions).typescriptChecker = { prioritizePerformanceOverAccuracy: true }; testInjector.options.tsconfigFile = resolveTestResource('empty-dir', 'tsconfig.json'); const sut = testInjector.injector.injectFunction(createTypescriptChecker); await expect(sut.init()).rejectedWith( 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 index 8866f25eac..6b9afaa9bb 100644 --- a/packages/typescript-checker/test/unit/fs/hybrid-file-system.spec.ts +++ b/packages/typescript-checker/test/unit/fs/hybrid-file-system.spec.ts @@ -1,7 +1,7 @@ import sinon from 'sinon'; import ts from 'typescript'; import { expect } from 'chai'; -import { factory, testInjector } from '@stryker-mutator/test-helpers'; +import { testInjector } from '@stryker-mutator/test-helpers'; import { HybridFileSystem } from '../../../src/fs/index.js'; @@ -92,6 +92,7 @@ describe('fs', () => { const watcherCallback = sinon.stub(); // Act + sut.writeFile('foo.js', 'some-content'); sut.watchFile('foo.js', watcherCallback); sut.writeFile('foo.js', 'some-content'); @@ -106,16 +107,6 @@ describe('fs', () => { expect(helper.readFileStub).calledWith('test/foo/a.js'); }); - it("should not throw if file isn't loaded", () => { - // Should ignore the file watch - const watchCallback = sinon.stub(); - sut.watchFile('node_modules/chai/package.json', watchCallback); - - // If it was successfully ignored, than `mutate` should throw - expect(() => sut.mutate(factory.mutant({ fileName: 'node_modules/chai/package.json' }))).throws(); - expect(watchCallback).not.called; - }); - it('should log that the file is watched', () => { helper.readFileStub.returns('foobar'); const watcherCallback = sinon.stub(); @@ -123,82 +114,15 @@ describe('fs', () => { expect(testInjector.logger.trace).calledWith('Registering watcher for file "%s"', 'foo.js'); }); }); - - 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', location: { start: { line: 0, column: 2 }, end: { line: 0, column: 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', location: { start: { line: 0, column: 2 }, end: { line: 0, column: 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', location: { start: { line: 0, column: 2 }, end: { line: 0, column: 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', location: { start: { line: 0, column: 2 }, end: { line: 0, column: 3 } }, replacement: '-' }); - sut.mutate({ fileName: 'b.js', location: { start: { line: 0, column: 6 }, end: { line: 0, column: 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', location: { start: { line: 0, column: 2 }, end: { line: 0, column: 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; + it('should return true if file does exists', () => { + const fileName = 'test-file'; + sut.writeFile(fileName, ''); + expect(sut.existsInMemory(fileName)).true; + }); + it('should return false if file does not exists', () => { + const fileName = 'test-file'; + expect(sut.existsInMemory(fileName)).false; }); }); }); diff --git a/packages/typescript-checker/test/unit/grouping/create-groups.spec.ts b/packages/typescript-checker/test/unit/grouping/create-groups.spec.ts new file mode 100644 index 0000000000..c5ed9b2b63 --- /dev/null +++ b/packages/typescript-checker/test/unit/grouping/create-groups.spec.ts @@ -0,0 +1,110 @@ +import { expect } from 'chai'; + +import { factory } from '@stryker-mutator/test-helpers'; + +import { TSFileNode } from '../../../src/grouping/ts-file-node.js'; + +import { createGroups } from '../../../src/grouping/create-groups.js'; + +describe(createGroups.name, () => { + it('single mutant should create single group', () => { + const mutants = [factory.mutant({ fileName: 'a.js', id: 'mutant-1' })]; + const nodes = new Map([['a.js', new TSFileNode('a.js', [], [])]]); + const groups = createGroups(mutants, nodes); + expect(groups).to.have.lengthOf(1); + expect(groups[0]).to.have.lengthOf(1); + expect(groups[0][0]).to.be.equal('mutant-1'); + }); + + it('two mutants in different files without reference to each other should create single group', () => { + const mutants = [factory.mutant({ fileName: 'a.js', id: '1' }), factory.mutant({ fileName: 'b.js', id: '2' })]; + const nodes = new Map([ + ['a.js', new TSFileNode('a.js', [], [])], + ['b.js', new TSFileNode('b.js', [], [])], + ]); + const groups = createGroups(mutants, nodes); + expect(groups).to.have.lengthOf(1); + expect(groups[0][0]).to.be.equal('1'); + expect(groups[0][1]).to.be.equal('2'); + }); + + it('two mutants in different files with reference to each other should create 2 groups', () => { + const mutants = [factory.mutant({ fileName: 'a.js', id: '1' }), factory.mutant({ fileName: 'b.js', id: '2' })]; + const nodeA = new TSFileNode('a.js', [], []); + const nodeB = new TSFileNode('b.js', [nodeA], []); + const nodes = new Map([ + [nodeA.fileName, nodeA], + [nodeB.fileName, nodeB], + ]); + const groups = createGroups(mutants, nodes); + expect(groups).to.have.lengthOf(2); + expect(groups[0][0]).to.be.equal('1'); + expect(groups[1][0]).to.be.equal('2'); + }); + + it('two mutants in different files with circular dependency to each other should create 2 groups', () => { + const mutants = [factory.mutant({ fileName: 'a.js', id: '1' }), factory.mutant({ fileName: 'b.js', id: '2' })]; + const nodeA = new TSFileNode('a.js', [], []); + const nodeB = new TSFileNode('b.js', [nodeA], []); + nodeA.parents.push(nodeB); + const nodes = new Map([ + [nodeA.fileName, nodeA], + [nodeB.fileName, nodeB], + ]); + const groups = createGroups(mutants, nodes); + expect(groups).to.have.lengthOf(2); + expect(groups[0][0]).to.be.equal('1'); + expect(groups[1][0]).to.be.equal('2'); + }); + + it('two mutants in same file should create 2 groups', () => { + const mutants = [factory.mutant({ fileName: 'a.js', id: '1' }), factory.mutant({ fileName: 'a.js', id: '2' })]; + const nodeA = new TSFileNode('a.js', [], []); + const nodes = new Map([[nodeA.fileName, nodeA]]); + const groups = createGroups(mutants, nodes); + expect(groups).to.have.lengthOf(2); + expect(groups[0][0]).to.be.equal('1'); + expect(groups[1][0]).to.be.equal('2'); + }); + + it('complex graph should contain multiples 4 groups', () => { + const mutants = [ + factory.mutant({ fileName: 'a.js', id: '1' }), + factory.mutant({ fileName: 'b.js', id: '2' }), + factory.mutant({ fileName: 'c.js', id: '3' }), + factory.mutant({ fileName: 'd.js', id: '4' }), + factory.mutant({ fileName: 'e.js', id: '5' }), + factory.mutant({ fileName: 'f.js', id: '6' }), + ]; + const nodeA = new TSFileNode('a.js', [], []); + const nodeB = new TSFileNode('b.js', [nodeA], []); + const nodeC = new TSFileNode('c.js', [nodeA], []); + const nodeD = new TSFileNode('d.js', [nodeC], []); + const nodeE = new TSFileNode('e.js', [nodeA], []); + const nodeF = new TSFileNode('f.js', [nodeE, nodeD], []); + const nodes = new Map([ + [nodeA.fileName, nodeA], + [nodeB.fileName, nodeB], + [nodeC.fileName, nodeC], + [nodeD.fileName, nodeD], + [nodeE.fileName, nodeE], + [nodeF.fileName, nodeF], + ]); + const groups = createGroups(mutants, nodes); + expect(groups).to.have.lengthOf(4); + expect(groups[0][0]).to.be.equal('1'); + expect(groups[1][0]).to.be.equal('2'); + expect(groups[1][1]).to.be.equal('3'); + expect(groups[1][2]).to.be.equal('5'); + expect(groups[2][0]).to.be.equal('4'); + expect(groups[3][0]).to.be.equal('6'); + }); + + it('should throw error when not is not in graph', () => { + const mutants = [factory.mutant({ fileName: 'a.js', id: '1' })]; + const nodeA = new TSFileNode('.js', [], []); + const nodes = new Map([[nodeA.fileName, nodeA]]); + + expect(createGroups.bind(null, mutants, nodes)).throw('Node not in graph: a.js'); + }); +}); diff --git a/packages/typescript-checker/test/unit/grouping/ts-file-node.spec.ts b/packages/typescript-checker/test/unit/grouping/ts-file-node.spec.ts new file mode 100644 index 0000000000..f8e3b97ea6 --- /dev/null +++ b/packages/typescript-checker/test/unit/grouping/ts-file-node.spec.ts @@ -0,0 +1,132 @@ +import { expect } from 'chai'; + +import { Mutant } from '@stryker-mutator/api/src/core'; + +import { TSFileNode } from '../../../src/grouping/ts-file-node.js'; + +describe('TSFileNode', () => { + describe(TSFileNode.prototype.getAllParentReferencesIncludingSelf.name, () => { + it('getAllParentReferencesIncludingSelf without parent should return array of 1 node ', () => { + const node = new TSFileNode('NodeA', [], []); + expect(node.getAllParentReferencesIncludingSelf()).to.have.lengthOf(1); + }); + + it('getAllParentReferencesIncludingSelf with 1 parent should return array of 2 nodes ', () => { + const node = new TSFileNode('NodeA', [new TSFileNode('', [], [])], []); + expect(node.getAllParentReferencesIncludingSelf()).to.have.lengthOf(2); + }); + + it('getAllParentReferencesIncludingSelf with recursive depth of 2 should return 3 nodes ', () => { + const node = new TSFileNode('NodeA', [new TSFileNode('', [new TSFileNode('', [], [])], [])], []); + expect(node.getAllParentReferencesIncludingSelf()).to.have.lengthOf(3); + }); + + it('getAllParentReferencesIncludingSelf with recursive depth of 2 and multiple parents should return 4 nodes ', () => { + const node = new TSFileNode('NodeA', [new TSFileNode('', [new TSFileNode('', [], []), new TSFileNode('', [], [])], [])], []); + expect(node.getAllParentReferencesIncludingSelf()).to.have.lengthOf(4); + }); + + it('getAllParentReferencesIncludingSelf with circular dependency should skip circular dependency node ', () => { + const nodeA = new TSFileNode('NodeA', [], []); + const nodeC = new TSFileNode('NodeB', [nodeA], []); + const nodeB = new TSFileNode('NodeB', [nodeC], []); + nodeA.parents.push(nodeB); + expect(nodeA.getAllParentReferencesIncludingSelf()).to.have.lengthOf(3); + }); + }); + + describe(TSFileNode.prototype.getAllChildReferencesIncludingSelf.name, () => { + it('getAllChildReferencesIncludingSelf without parent should return array of 1 node ', () => { + const node = new TSFileNode('NodeA', [], []); + expect(node.getAllChildReferencesIncludingSelf()).to.have.lengthOf(1); + }); + + it('getAllChildReferencesIncludingSelf with 1 child should return array of 2 nodes ', () => { + const node = new TSFileNode('NodeA', [], [new TSFileNode('', [], [])]); + expect(node.getAllChildReferencesIncludingSelf()).to.have.lengthOf(2); + }); + + it('getAllChildReferencesIncludingSelf with recursive depth of 2 should return 3 nodes ', () => { + const node = new TSFileNode('NodeA', [], [new TSFileNode('', [], [new TSFileNode('', [], [])])]); + expect(node.getAllChildReferencesIncludingSelf()).to.have.lengthOf(3); + }); + + it('getAllChildReferencesIncludingSelf with recursive depth of 2 and multiple parents should return 4 nodes ', () => { + const node = new TSFileNode('NodeA', [], [new TSFileNode('', [], [new TSFileNode('', [], []), new TSFileNode('', [], [])])]); + expect(node.getAllChildReferencesIncludingSelf()).to.have.lengthOf(4); + }); + }); + + describe(TSFileNode.prototype.getMutantsWithReferenceToChildrenOrSelf.name, () => { + it('getMutantsWithReferenceToChildrenOrSelf with single mutant in file should return 1 mutant', () => { + const node = new TSFileNode('NodeA.js', [], []); + const mutants: Mutant[] = [ + { + fileName: 'NodeA.js', + id: '0', + replacement: '-', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, + mutatorName: '', + }, + ]; + expect(node.getMutantsWithReferenceToChildrenOrSelf(mutants)).to.have.lengthOf(1); + }); + + it('getMutantsWithReferenceToChildrenOrSelf with single mutant in child should return 1 mutant', () => { + const node = new TSFileNode('NodeA.js', [], []); + const nodeB = new TSFileNode('NodeB.js', [], []); + node.children.push(nodeB); + const mutants: Mutant[] = [ + { + fileName: 'NodeB.js', + id: '0', + replacement: '-', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, + mutatorName: '', + }, + ]; + expect(node.getMutantsWithReferenceToChildrenOrSelf(mutants)).to.have.lengthOf(1); + }); + + it('should not create endless loop', () => { + const node = new TSFileNode('NodeA.js', [], []); + node.children = [node]; + + const mutants: Mutant[] = [ + { + fileName: 'NodeA.js', + id: '0', + replacement: '-', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, + mutatorName: '', + }, + ]; + + expect(node.getMutantsWithReferenceToChildrenOrSelf(mutants)).to.have.lengthOf(1); + }); + + it('should find mutant with backward slashes and forward slashes', () => { + const node = new TSFileNode('path/NodeA.js', [], []); + node.children = [node]; + + const mutants: Mutant[] = [ + { + fileName: 'path/NodeA.js', + id: '0', + replacement: '-', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, + mutatorName: '', + }, + { + fileName: 'path\\NodeA.js', + id: '0', + replacement: '-', + location: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, + mutatorName: '', + }, + ]; + + expect(node.getMutantsWithReferenceToChildrenOrSelf(mutants)).to.have.lengthOf(2); + }); + }); +}); diff --git a/packages/typescript-checker/test/unit/typescript-checker.spec.ts b/packages/typescript-checker/test/unit/typescript-checker.spec.ts new file mode 100644 index 0000000000..c20285a4fd --- /dev/null +++ b/packages/typescript-checker/test/unit/typescript-checker.spec.ts @@ -0,0 +1,82 @@ +import { testInjector, factory } from '@stryker-mutator/test-helpers'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { strykerReportBugUrl } from '@stryker-mutator/util'; +import ts from 'typescript'; + +import { TypescriptChecker } from '../../src/typescript-checker.js'; +import { TypescriptCheckerOptionsWithStrykerOptions } from '../../src/typescript-checker-options-with-stryker-options'; +import { TypescriptCompiler } from '../../src/typescript-compiler.js'; +import { TSFileNode } from '../../src/grouping/ts-file-node.js'; +import * as pluginTokens from '../../src/plugin-tokens.js'; +import { createTSDiagnostic } from '../helpers.js'; + +describe(TypescriptChecker.name, () => { + let sut: TypescriptChecker; + let compilerMock: sinon.SinonStubbedInstance; + let options: TypescriptCheckerOptionsWithStrykerOptions; + let nodes: Map; + beforeEach(() => { + nodes = new Map(); + options = testInjector.options as TypescriptCheckerOptionsWithStrykerOptions; + options.typescriptChecker = { prioritizePerformanceOverAccuracy: true }; + compilerMock = sinon.createStubInstance(TypescriptCompiler); + sinon.stub(compilerMock, 'nodes').get(() => nodes); + sut = testInjector.injector.provideValue(pluginTokens.tsCompiler, compilerMock).injectClass(TypescriptChecker); + }); + + describe(TypescriptChecker.prototype.group.name, () => { + it('should not group mutants if prioritizePerformanceOverAccuracy is false', async () => { + options.typescriptChecker.prioritizePerformanceOverAccuracy = false; + const result = await sut.group([factory.mutant({ id: '1' }), factory.mutant({ id: '2' }), factory.mutant({ id: '3' })]); + expect(result).lengthOf(3); + expect(result.reduce((prev, group) => prev + group.length, 0)).eq(3); + }); + + it('should group mutants if prioritizePerformanceOverAccuracy is true', async () => { + options.typescriptChecker.prioritizePerformanceOverAccuracy = true; + const result = await sut.group([factory.mutant(), factory.mutant(), factory.mutant()]); + expect(result).lengthOf(1); + }); + + it('should not add an empty group when there are no mutants that fall outside of the project', async () => { + const mutants = [factory.mutant({ fileName: 'foo.ts', id: '41' })]; + compilerMock.nodes.set('foo.ts', new TSFileNode('foo.ts', [], [])); + + const result = await sut.group(mutants); + expect(result).lengthOf(1); + expect(result[0]).lengthOf(1); + }); + }); + + describe(TypescriptChecker.prototype.check.name, () => { + it("should reject when errors don't provide a file name", async () => { + // Arrange + const mutants = [factory.mutant({ fileName: 'foo.ts', id: '41' }), factory.mutant({ fileName: 'bar.js', id: '42' })]; + compilerMock.nodes.set('foo.ts', new TSFileNode('foo.ts', [], [])); + compilerMock.check.resolves([createTSDiagnostic({ file: undefined, messageText: 'fooError' })]); + + // Act + await expect(sut.check(mutants)).rejectedWith( + `Typescript error: 'fooError' was reported without a corresponding file. This shouldn't happen. Please open an issue using this link: ${strykerReportBugUrl( + '[BUG]: TypeScript checker reports compile error without a corresponding file: fooError' + )}` + ); + }); + it('should reject when errors relate to an unrelated file', async () => { + // Arrange + const mutants = [factory.mutant({ fileName: 'foo.ts', id: '41' }), factory.mutant({ fileName: 'foo.ts', id: '42' })]; + compilerMock.nodes.set('foo.ts', new TSFileNode('foo.ts', [], [])); + compilerMock.check.resolves([ + createTSDiagnostic({ file: ts.createSourceFile('bar.ts', '', ts.ScriptTarget.Latest, false, undefined), messageText: 'fooError' }), + ]); + + // Act + await expect(sut.check(mutants)).rejectedWith( + `Typescript error: 'fooError' was reported in an unrelated file (bar.ts). This file is not part of your project, or referenced from your project. This shouldn't happen, please open an issue using this link: ${strykerReportBugUrl( + '[BUG]: TypeScript checker reports compile error in an unrelated file: fooError' + )}` + ); + }); + }); +}); diff --git a/packages/typescript-checker/test/unit/typescript-helpers.spec.ts b/packages/typescript-checker/test/unit/typescript-helpers.spec.ts index 990eaa9910..8770c84c10 100644 --- a/packages/typescript-checker/test/unit/typescript-helpers.spec.ts +++ b/packages/typescript-checker/test/unit/typescript-helpers.spec.ts @@ -4,7 +4,13 @@ import sinon from 'sinon'; import ts from 'typescript'; import { expect } from 'chai'; -import { determineBuildModeEnabled, overrideOptions, retrieveReferencedProjects, guardTSVersion } from '../../src/tsconfig-helpers.js'; +import { + determineBuildModeEnabled, + overrideOptions, + retrieveReferencedProjects, + guardTSVersion, + getSourceMappingURL, +} from '../../src/tsconfig-helpers.js'; describe('typescript-helpers', () => { describe(determineBuildModeEnabled.name, () => { @@ -53,8 +59,6 @@ describe('typescript-helpers', () => { noEmit: true, incremental: false, composite: false, - declaration: false, - declarationMap: false, }); expect( JSON.parse( @@ -65,8 +69,6 @@ describe('typescript-helpers', () => { noEmit: false, incremental: true, composite: true, - declaration: true, - declarationMap: false, }, }, }, @@ -77,8 +79,6 @@ describe('typescript-helpers', () => { noEmit: true, incremental: false, composite: false, - declaration: false, - declarationMap: false, }); }); @@ -93,7 +93,7 @@ describe('typescript-helpers', () => { incremental: true, composite: true, declaration: true, - declarationMap: false, + declarationMap: true, declarationDir: '.', }, }, @@ -112,7 +112,7 @@ describe('typescript-helpers', () => { incremental: true, composite: true, declaration: true, - declarationMap: false, + declarationMap: true, declarationDir: '', }, }, @@ -127,7 +127,7 @@ describe('typescript-helpers', () => { expect(JSON.parse(overrideOptions({ config: {} }, true)).compilerOptions).deep.include({ emitDeclarationOnly: true, noEmit: false, - declarationMap: false, + declarationMap: true, }); expect( JSON.parse( @@ -147,9 +147,37 @@ describe('typescript-helpers', () => { ).deep.include({ emitDeclarationOnly: true, noEmit: false, - declarationMap: false, + declarationMap: true, }); }); + + it('should set --declarationMap and --declaration options when `--build` mode is on', () => { + expect(JSON.parse(overrideOptions({ config: { declarationMap: false, declaration: false } }, true)).compilerOptions).deep.include({ + declarationMap: true, + declaration: true, + }); + }); + + it('should delete declarations properties if `--build` mode is on', () => { + expect( + JSON.parse( + overrideOptions( + { + config: { + compilerOptions: { + inlineSourceMap: '.', + inlineSources: '.', + mapRoute: '.', + sourceRoot: '.', + outFile: '.', + }, + }, + }, + true + ) + ).compilerOptions + ).to.not.have.any.keys('inlineSourceMap', 'inlineSources', 'mapRoute', 'sourceRoot', 'outFile'); + }); }); describe(retrieveReferencedProjects.name, () => { @@ -169,7 +197,7 @@ describe('typescript-helpers', () => { 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'); + expect(guardTSVersion).throws('@stryker-mutator/typescript-checker only supports typescript@3.6 or higher. Found typescript@3.5.0'); }); it('should not throw if typescript@3.6.0', () => { sinon.stub(ts, 'version').value('3.6.0'); @@ -180,4 +208,27 @@ describe('typescript-helpers', () => { expect(guardTSVersion).not.throws(); }); }); + + describe(getSourceMappingURL.name, () => { + it('should return undefined when no sourceMap is provided', () => { + const content = 'let sum = 2 + 6;'; + const result = getSourceMappingURL(content); + expect(result).to.be.undefined; + }); + + it('should be able to get multiple sourceFiles in sequence', () => { + const content = '//# sourceMappingURL=/url.ts'; + const result1 = getSourceMappingURL(content); + const result2 = getSourceMappingURL(content); + expect(result1).to.be.eq('/url.ts'); + expect(result2).to.be.eq('/url.ts'); + }); + + it('should not hit when sourceMappingURL is not on the end of the file', () => { + const content = `const regex = //# sourceMappingURL=/url.ts + console.log(regex);`; + const result = getSourceMappingURL(content); + expect(result).to.be.undefined; + }); + }); }); diff --git a/packages/typescript-checker/testResources/project-references/src/index.ts b/packages/typescript-checker/testResources/project-references/src/index.ts new file mode 100644 index 0000000000..407ccaaa23 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/src/index.ts @@ -0,0 +1,5 @@ +import { count } from '../utils/math.js'; + +export function countArrayLength(todo: any[]): number { + return count(todo); +} diff --git a/packages/typescript-checker/testResources/project-references/src/job.ts b/packages/typescript-checker/testResources/project-references/src/job.ts new file mode 100644 index 0000000000..b27edc10e4 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/src/job.ts @@ -0,0 +1,6 @@ +import { toUpperCase } from '../utils/text.js'; + +export function start(): void { + const logText = "Starting job"; + console.log(toUpperCase(logText)); +} diff --git a/packages/typescript-checker/testResources/project-references/src/src.tsbuildinfo b/packages/typescript-checker/testResources/project-references/src/src.tsbuildinfo new file mode 100644 index 0000000000..61a22ff808 --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/src/src.tsbuildinfo @@ -0,0 +1 @@ +{"program":{"fileNames":["../../../../../node_modules/typescript/lib/lib.d.ts","../../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../../node_modules/typescript/lib/lib.scripthost.d.ts","../dist/utils/math.d.ts","./index.ts","../dist/utils/text.d.ts","./job.ts"],"fileInfos":["2dc8c927c9c162a773c6bb3cdc4f3286c23f10eedc67414028f9cb5951610f60",{"version":"f20c05dbfe50a208301d2a1da37b9931bce0466eb5a1f4fe240971b4ecc82b67","affectsGlobalScope":true},{"version":"9b087de7268e4efc5f215347a62656663933d63c0b1d7b624913240367b999ea","affectsGlobalScope":true},{"version":"7fac8cb5fc820bc2a59ae11ef1c5b38d3832c6d0dfaec5acdb5569137d09a481","affectsGlobalScope":true},{"version":"097a57355ded99c68e6df1b738990448e0bf170e606707df5a7c0481ff2427cd","affectsGlobalScope":true},"80dbf481ae698a44d6d4b60f3c36d84a94b2a5eb14927eae5347b82f33ec0277",{"version":"c9b6bdd48b8bdb8d8e7690c7cc18897a494b6ab17dc58083dacfaf14b846ab4f","signature":"40b6409b8d0dced1f6c3964012b7a7c1cd50e24c3242095d1c8cfc6cabe8bd31"},"cdf6a65d46d64de68df5d8a322621f74327b1ee02c3fde41f736e11d307fcfb1",{"version":"e4c28c497fe6cc6364b113c181c32ba58e70f02d824295e72b15d9570b403104","signature":"9be66c79f48b4876970daed5167e069d7f12f1a1ca616ecaa0ca8280946344ca"}],"options":{"composite":true,"declaration":true,"declarationMap":true,"module":1,"noUnusedLocals":true,"noUnusedParameters":true,"outDir":"../dist/src","strict":true,"target":1,"tsBuildInfoFile":"./src.tsbuildinfo"},"fileIdsList":[[6],[8]],"referencedMap":[[7,1],[9,2]],"exportedModulesMap":[],"semanticDiagnosticsPerFile":[1,3,2,5,4,6,8,7,9],"latestChangedDtsFile":"../dist/src/job.d.ts"},"version":"4.8.4"} \ No newline at end of file diff --git a/packages/typescript-checker/testResources/project-references/src/tsconfig.json b/packages/typescript-checker/testResources/project-references/src/tsconfig.json index af64ba5a29..4ef4b2942f 100644 --- a/packages/typescript-checker/testResources/project-references/src/tsconfig.json +++ b/packages/typescript-checker/testResources/project-references/src/tsconfig.json @@ -1,6 +1,10 @@ { "extends": "../tsconfig.settings", "compilerOptions": { + "tsBuildInfoFile": "src.tsbuildinfo", "outDir": "../dist/src" - } + }, + "references": [ + { "path": "../utils" } + ] } diff --git a/packages/typescript-checker/testResources/project-references/test/tsconfig.json b/packages/typescript-checker/testResources/project-references/test/tsconfig.json deleted file mode 100644 index 22f321ef72..0000000000 --- a/packages/typescript-checker/testResources/project-references/test/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 index d54e2806ef..d0aa0fdd2a 100644 --- a/packages/typescript-checker/testResources/project-references/tsconfig.root.json +++ b/packages/typescript-checker/testResources/project-references/tsconfig.root.json @@ -2,6 +2,6 @@ "files": [], "references": [ { "path": "./src" }, - { "path": "./test" } + { "path": "./utils" } ] } diff --git a/packages/typescript-checker/testResources/project-references/utils/math.ts b/packages/typescript-checker/testResources/project-references/utils/math.ts new file mode 100644 index 0000000000..23cbcf7eda --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/utils/math.ts @@ -0,0 +1,3 @@ +export function count(array: any[]) { + return array.length; +} diff --git a/packages/typescript-checker/testResources/project-references/utils/text.ts b/packages/typescript-checker/testResources/project-references/utils/text.ts new file mode 100644 index 0000000000..33d172474d --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/utils/text.ts @@ -0,0 +1,3 @@ +export function toUpperCase(text: string) { + return text.toUpperCase(); +} diff --git a/packages/typescript-checker/testResources/project-references/utils/tsconfig.json b/packages/typescript-checker/testResources/project-references/utils/tsconfig.json new file mode 100644 index 0000000000..e7f2d3376c --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.settings", + "compilerOptions": { + "outDir": "../dist/utils", + "tsBuildInfoFile": "utils.tsbuildinfo" + } +} diff --git a/packages/typescript-checker/testResources/project-references/utils/utils.tsbuildinfo b/packages/typescript-checker/testResources/project-references/utils/utils.tsbuildinfo new file mode 100644 index 0000000000..5901147f8f --- /dev/null +++ b/packages/typescript-checker/testResources/project-references/utils/utils.tsbuildinfo @@ -0,0 +1 @@ +{"program":{"fileNames":["../../../../../node_modules/typescript/lib/lib.d.ts","../../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../../node_modules/typescript/lib/lib.scripthost.d.ts","./math.ts","./text.ts"],"fileInfos":["2dc8c927c9c162a773c6bb3cdc4f3286c23f10eedc67414028f9cb5951610f60",{"version":"f20c05dbfe50a208301d2a1da37b9931bce0466eb5a1f4fe240971b4ecc82b67","affectsGlobalScope":true},{"version":"9b087de7268e4efc5f215347a62656663933d63c0b1d7b624913240367b999ea","affectsGlobalScope":true},{"version":"7fac8cb5fc820bc2a59ae11ef1c5b38d3832c6d0dfaec5acdb5569137d09a481","affectsGlobalScope":true},{"version":"097a57355ded99c68e6df1b738990448e0bf170e606707df5a7c0481ff2427cd","affectsGlobalScope":true},{"version":"6198e7d4a43aabb174a72ec9f0e8d2962912ad59ad90010aac3930868a8f62a4","signature":"0400cb85cef49e897c47df13e38b5cd199e0c900253f2d2ddf2e3491c27bc0a8"},{"version":"becd081df112726ab94c1ca1c05d6a59268fe0dabf7ad076d16ea851bf99e8fb","signature":"6039d94241358544e8d62a3a0ba90752a9973b3b2b422c187e2bcf7256fcda2e"}],"options":{"composite":true,"declaration":true,"declarationMap":true,"module":1,"noUnusedLocals":true,"noUnusedParameters":true,"outDir":"../dist/utils","strict":true,"target":1,"tsBuildInfoFile":"./utils.tsbuildinfo"},"referencedMap":[],"exportedModulesMap":[],"semanticDiagnosticsPerFile":[1,3,2,5,4,6,7],"latestChangedDtsFile":"../dist/utils/text.d.ts"},"version":"4.8.4"} \ No newline at end of file diff --git a/packages/typescript-checker/testResources/project-with-ts-buildinfo/do-not-delete.tsbuildinfo b/packages/typescript-checker/testResources/project-with-ts-buildinfo/do-not-delete.tsbuildinfo new file mode 100644 index 0000000000..814ef00bf7 --- /dev/null +++ b/packages/typescript-checker/testResources/project-with-ts-buildinfo/do-not-delete.tsbuildinfo @@ -0,0 +1 @@ +{"program":{"fileNames":["../../../../node_modules/typescript/lib/lib.d.ts","../../../../node_modules/typescript/lib/lib.es5.d.ts","../../../../node_modules/typescript/lib/lib.dom.d.ts","../../../../node_modules/typescript/lib/lib.webworker.importscripts.d.ts","../../../../node_modules/typescript/lib/lib.scripthost.d.ts","./src/index.ts"],"fileInfos":["2dc8c927c9c162a773c6bb3cdc4f3286c23f10eedc67414028f9cb5951610f60",{"version":"f20c05dbfe50a208301d2a1da37b9931bce0466eb5a1f4fe240971b4ecc82b67","affectsGlobalScope":true},{"version":"9b087de7268e4efc5f215347a62656663933d63c0b1d7b624913240367b999ea","affectsGlobalScope":true},{"version":"7fac8cb5fc820bc2a59ae11ef1c5b38d3832c6d0dfaec5acdb5569137d09a481","affectsGlobalScope":true},{"version":"097a57355ded99c68e6df1b738990448e0bf170e606707df5a7c0481ff2427cd","affectsGlobalScope":true},"39441a0f0f37ba8f1a5ef3bf717953d53469cfd7a8450a8a2221959a67905497"],"options":{"module":1,"noUnusedLocals":true,"noUnusedParameters":true,"outDir":"./dist","strict":true,"target":1,"tsBuildInfoFile":"./do-not-delete.tsbuildinfo"},"referencedMap":[],"exportedModulesMap":[],"semanticDiagnosticsPerFile":[1,3,2,5,4,6]},"version":"4.8.4"} \ No newline at end of file diff --git a/packages/typescript-checker/testResources/project-with-ts-buildinfo/src/index.ts b/packages/typescript-checker/testResources/project-with-ts-buildinfo/src/index.ts new file mode 100644 index 0000000000..3b399665dc --- /dev/null +++ b/packages/typescript-checker/testResources/project-with-ts-buildinfo/src/index.ts @@ -0,0 +1,3 @@ +export function add(a: number, b: number) { + return a + b; +} diff --git a/packages/typescript-checker/testResources/project-with-ts-buildinfo/tsconfig.json b/packages/typescript-checker/testResources/project-with-ts-buildinfo/tsconfig.json new file mode 100644 index 0000000000..3614c9ead5 --- /dev/null +++ b/packages/typescript-checker/testResources/project-with-ts-buildinfo/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es5", + "moduleResolution": "node", + "module": "commonjs", + "outDir": "dist", + + // These settings should be overridden by the typescript checker + "noUnusedLocals": true, + "noUnusedParameters": true, + + "types": [], + "incremental": true, + "tsBuildInfoFile": "do-not-delete.tsbuildinfo" + } +} diff --git a/packages/typescript-checker/testResources/single-project/src/counter.ts b/packages/typescript-checker/testResources/single-project/src/counter.ts new file mode 100644 index 0000000000..bf3f92304e --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/src/counter.ts @@ -0,0 +1,9 @@ +export class Counter { + constructor(private currentNumber: number) {} + public increment(numberToIncrementBy = 1): number { + return (this.currentNumber += numberToIncrementBy); + } + get getCurrentNumber(): number { + return this.currentNumber; + } +} diff --git a/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/counter.ts b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/counter.ts new file mode 100644 index 0000000000..37fef80437 --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/counter.ts @@ -0,0 +1,9 @@ +export class Counter { + constructor(private currentNumber: number) {} + public increment(numberToIncrementBy = 1) { + return (this.currentNumber += numberToIncrementBy); + } + get getCurrentNumber(): number { + return this.currentNumber; + } +} diff --git a/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo-counter.ts b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo-counter.ts new file mode 100644 index 0000000000..4fda514818 --- /dev/null +++ b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo-counter.ts @@ -0,0 +1,7 @@ +import { Counter } from './counter'; +import { TodoList } from './todo'; + +const counter = new Counter(1); +const todoList = new TodoList(); +todoList.createTodoItem('test', 'test description'); +const newCount: number = counter.increment(); diff --git a/packages/typescript-checker/testResources/project-references/test/todo.spec.ts b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo.spec.ts similarity index 84% rename from packages/typescript-checker/testResources/project-references/test/todo.spec.ts rename to packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo.spec.ts index 60dc360347..dca331ed86 100644 --- a/packages/typescript-checker/testResources/project-references/test/todo.spec.ts +++ b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo.spec.ts @@ -1,4 +1,4 @@ -import { TodoList } from '../src/todo.js'; +import { TodoList } from './todo.js'; const list = new TodoList(); const n: number = list.createTodoItem('Mow lawn', 'Mow moving forward.') @@ -8,4 +8,5 @@ function addTodo(name = 'test', description = 'test') { list.createTodoItem(name, description); } + addTodo(); diff --git a/packages/typescript-checker/testResources/project-references/src/todo.ts b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo.ts similarity index 94% rename from packages/typescript-checker/testResources/project-references/src/todo.ts rename to packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo.ts index 7ac1dd6631..966e627313 100644 --- a/packages/typescript-checker/testResources/project-references/src/todo.ts +++ b/packages/typescript-checker/testResources/single-project/src/errorInFileAbove2Mutants/todo.ts @@ -5,7 +5,7 @@ export interface ITodo { } class Todo implements ITodo { - constructor(public name: string, public description: string, public completed: boolean) { } + constructor(public name: string, public description: string, public completed: boolean) {} } export class TodoList { @@ -20,4 +20,3 @@ export class TodoList { return TodoList.allTodos; } } - diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 46e2c85eca..b6161078ca 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -13,3 +13,4 @@ export * from './require-resolve.js'; export * from './deep-merge.js'; export * from './find-unserializables.js'; export * from './platform.js'; +export * from './split.js'; diff --git a/packages/util/src/split.ts b/packages/util/src/split.ts new file mode 100644 index 0000000000..b5dcdfb64b --- /dev/null +++ b/packages/util/src/split.ts @@ -0,0 +1,13 @@ +export function split(values: Iterable, predicate: (value: T, index: number) => boolean): [T[], T[]] { + const left: T[] = []; + const right: T[] = []; + let index = 0; + for (const value of values) { + if (predicate(value, index++)) { + left.push(value); + } else { + right.push(value); + } + } + return [left, right]; +} diff --git a/packages/util/src/string-utils.ts b/packages/util/src/string-utils.ts index 03a9e19d7c..6fd3b1f36f 100644 --- a/packages/util/src/string-utils.ts +++ b/packages/util/src/string-utils.ts @@ -57,3 +57,13 @@ export function escapeRegExp(input: string): string { export function normalizeFileName(fileName: string): string { return fileName.replace(/\\/g, '/'); } + +/** + * Creates a URL to the page where you can report a bug. + * @param titleSuggestion The title to be prefilled in. + */ +export function strykerReportBugUrl(titleSuggestion: string): string { + return `https://github.com/stryker-mutator/stryker-js/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug_report.md&title=${encodeURIComponent( + titleSuggestion + )}`; +} diff --git a/packages/util/test/unit/split.spec.ts b/packages/util/test/unit/split.spec.ts new file mode 100644 index 0000000000..1645afa843 --- /dev/null +++ b/packages/util/test/unit/split.spec.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; + +import { split } from '../../src/index.js'; + +describe(split.name, () => { + it('should split into empty arrays when input is empty', () => { + const [left, right] = split([], () => true); + + expect(left).lengthOf(0); + expect(right).lengthOf(0); + }); + + it('should split all values left when predicate is true', () => { + const input = [1, 2, 3]; + const [left, right] = split(input, () => true); + + expect(left).deep.eq(input); + expect(right).lengthOf(0); + }); + + it('should split all values right when predicate is false', () => { + const input = [1, 2, 3]; + const [left, right] = split(input, () => false); + + expect(right).deep.eq(input); + expect(left).lengthOf(0); + }); + + it('should provide value to predicate', () => { + const input = [1, 2, 3]; + const [left, right] = split(input, (value) => value % 2 === 0); + + expect(left).deep.eq([2]); + expect(right).deep.eq([1, 3]); + }); + + it('should provide index to predicate', () => { + const input = ['f', 'o', 'b']; + const [left, right] = split(input, (_, index) => index % 2 === 0); + + expect(left).deep.eq(['f', 'b']); + expect(right).deep.eq(['o']); + }); +}); diff --git a/packages/util/test/unit/string-utils.spec.ts b/packages/util/test/unit/string-utils.spec.ts index 72105b91cf..e30e5858a2 100644 --- a/packages/util/test/unit/string-utils.spec.ts +++ b/packages/util/test/unit/string-utils.spec.ts @@ -1,6 +1,14 @@ import { expect } from 'chai'; -import { normalizeWhitespaces, propertyPath, escapeRegExpLiteral, escapeRegExp, normalizeFileName, normalizeLineEndings } from '../../src/index.js'; +import { + normalizeWhitespaces, + propertyPath, + escapeRegExpLiteral, + escapeRegExp, + normalizeFileName, + normalizeLineEndings, + strykerReportBugUrl, +} from '../../src/index.js'; describe('stringUtils', () => { describe(normalizeWhitespaces.name, () => { @@ -119,4 +127,16 @@ describe('stringUtils', () => { expect(normalizeFileName('test/util/foo.spec.js')).eq('test/util/foo.spec.js'); }); }); + + describe(strykerReportBugUrl.name, () => { + it('should format a correct url', () => { + expect(strykerReportBugUrl('theTitle')).eq( + 'https://github.com/stryker-mutator/stryker-js/issues/new?assignees=&labels=%F0%9F%90%9B+Bug&template=bug_report.md&title=theTitle' + ); + }); + + it('should url-encode the title suggestion', () => { + expect(strykerReportBugUrl('the title').endsWith('the&title')); + }); + }); }); diff --git a/workspace.code-workspace b/workspace.code-workspace index 889e946268..bd4ceecd85 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -116,7 +116,7 @@ "Whitespaces" ], "cSpell.language": "en", - "liveServer.settings.multiRootWorkspaceName": "core" + "liveServer.settings.multiRootWorkspaceName": "typescript-checker" }, "extensions": { "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"]