@systemfsoftware/stryker-js-mutation-run 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +212 -0
- package/README.md +38 -0
- package/dist/checker-C4tGiXtZ.mjs +588 -0
- package/dist/checker-resource-DTYEKXdg.d.mts +108 -0
- package/dist/checker-worker.d.mts +16 -0
- package/dist/checker-worker.mjs +2 -0
- package/dist/child-process-proxy-worker-lx1M5Mz3.mjs +567 -0
- package/dist/child-process-proxy-worker-main.d.mts +1 -0
- package/dist/child-process-proxy-worker-main.mjs +6 -0
- package/dist/child-process-test-runner-worker-BTWRYb8V.mjs +50 -0
- package/dist/child-process-test-runner-worker.d.mts +16 -0
- package/dist/child-process-test-runner-worker.mjs +2 -0
- package/dist/config/base.d.mts +21 -0
- package/dist/config/base.mjs +47 -0
- package/dist/config/config-resolution.d.mts +80 -0
- package/dist/config/config-resolution.mjs +2 -0
- package/dist/config/fork-schema.d.mts +5 -0
- package/dist/config/fork-schema.mjs +2 -0
- package/dist/errors-BkjxkY_S.d.mts +6 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +10 -0
- package/dist/exit-classification.d.mts +28 -0
- package/dist/exit-classification.mjs +39 -0
- package/dist/fork-schema-BHuTbRBR.mjs +62 -0
- package/dist/incremental-differ-DDVwtXBn.d.mts +72 -0
- package/dist/incremental-differ-DY8AA09Q.mjs +579 -0
- package/dist/index-CyOSz6LC.d.mts +104 -0
- package/dist/index.d.mts +87 -0
- package/dist/index.mjs +1885 -0
- package/dist/mutants/incremental-differ.d.mts +2 -0
- package/dist/mutants/incremental-differ.mjs +2 -0
- package/dist/output-mode-CfGHDpi5.d.mts +17 -0
- package/dist/output-mode.d.mts +2 -0
- package/dist/output-mode.mjs +1 -0
- package/dist/plugins-Nx86IJQW.mjs +1618 -0
- package/dist/plugins.d.mts +2 -0
- package/dist/plugins.mjs +2 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/run-event-CzRz9Gtk.d.mts +121 -0
- package/dist/run-event.d.mts +2 -0
- package/dist/run-event.mjs +1 -0
- package/dist/stryker-package-CTcsIZ4S.mjs +23 -0
- package/dist/stryker-package.d.mts +7 -0
- package/dist/stryker-package.mjs +2 -0
- package/dist/timer-BMrE1SQ3.d.mts +20 -0
- package/dist/timer.d.mts +2 -0
- package/dist/timer.mjs +48 -0
- package/dist/verdict-envelope-BWc61mHm.mjs +229 -0
- package/dist/verdict-envelope-CKLZPjxC.d.mts +94 -0
- package/dist/verdict-envelope.d.mts +2 -0
- package/dist/verdict-envelope.mjs +2 -0
- package/package.json +102 -0
- package/schema/stryker-schema.json +903 -0
|
@@ -0,0 +1,1618 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { ConfigError } from "./errors.mjs";
|
|
3
|
+
import { Timer } from "./timer.mjs";
|
|
4
|
+
import { n as REMOVED_OPTIONS } from "./fork-schema-BHuTbRBR.mjs";
|
|
5
|
+
import { INSTRUMENTER_CONSTANTS, StrykerOptionsSchema } from "@systemfsoftware/stryker-js-plugin-api/core";
|
|
6
|
+
import { commonTokens, tokens } from "@systemfsoftware/stryker-js-plugin-api/plugin";
|
|
7
|
+
import "typed-inject";
|
|
8
|
+
import { ERROR_CODES, StrykerError, deepFreeze, deepMerge, errorToString, findUnserializables, isErrnoException, normalizeFileName, normalizeWhitespaces, notEmpty, propertyPath, testFilesProvided } from "@stryker-mutator/util";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import { isDeepStrictEqual } from "util";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { URL, fileURLToPath, pathToFileURL } from "url";
|
|
13
|
+
import { Match, Schema } from "effect";
|
|
14
|
+
import "@systemfsoftware/stryker-js-plugin-api/logging";
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
import os from "os";
|
|
17
|
+
import { Minimatch, minimatch } from "minimatch";
|
|
18
|
+
import * as Result$1 from "effect/Result";
|
|
19
|
+
import * as S from "effect/Schema";
|
|
20
|
+
import { Subject, mergeMap } from "rxjs";
|
|
21
|
+
import "@stryker-mutator/instrumenter";
|
|
22
|
+
import "mutation-testing-report-schema";
|
|
23
|
+
import * as Match$1 from "effect/Match";
|
|
24
|
+
import { exec } from "child_process";
|
|
25
|
+
import { DryRunStatus, TestStatus, toMutantRunResult } from "@systemfsoftware/stryker-js-plugin-api/test-runner";
|
|
26
|
+
import treeKill from "tree-kill";
|
|
27
|
+
//#region src/plugins/injection-tokens.ts
|
|
28
|
+
var injection_tokens_exports = /* @__PURE__ */ __exportAll({
|
|
29
|
+
checkerConcurrencyTokens: () => checkerConcurrencyTokens,
|
|
30
|
+
checkerFactory: () => checkerFactory,
|
|
31
|
+
checkerPool: () => checkerPool,
|
|
32
|
+
clearTextEnabled: () => clearTextEnabled,
|
|
33
|
+
concurrencyTokenProvider: () => concurrencyTokenProvider,
|
|
34
|
+
disableTypeChecksHelper: () => disableTypeChecksHelper,
|
|
35
|
+
dryRunResult: () => dryRunResult,
|
|
36
|
+
execa: () => execa,
|
|
37
|
+
execaSync: () => execaSync,
|
|
38
|
+
fs: () => "fs",
|
|
39
|
+
incrementalDiffer: () => incrementalDiffer,
|
|
40
|
+
loggerActiveLevel: () => loggerActiveLevel,
|
|
41
|
+
loggerConsoleOut: () => loggerConsoleOut,
|
|
42
|
+
loggerShowColors: () => loggerShowColors,
|
|
43
|
+
loggingServer: () => loggingServer,
|
|
44
|
+
loggingServerAddress: () => loggingServerAddress,
|
|
45
|
+
loggingSink: () => loggingSink,
|
|
46
|
+
mutantTestPlanner: () => mutantTestPlanner,
|
|
47
|
+
mutants: () => mutants,
|
|
48
|
+
mutationTestReportHelper: () => mutationTestReportHelper,
|
|
49
|
+
optionsValidator: () => optionsValidator,
|
|
50
|
+
pluginCreator: () => pluginCreator,
|
|
51
|
+
pluginModulePaths: () => pluginModulePaths,
|
|
52
|
+
pluginsByKind: () => pluginsByKind,
|
|
53
|
+
process: () => process$1,
|
|
54
|
+
progressEnabled: () => progressEnabled,
|
|
55
|
+
project: () => project,
|
|
56
|
+
reporter: () => reporter,
|
|
57
|
+
reporterOverride: () => reporterOverride,
|
|
58
|
+
reporterPluginModules: () => reporterPluginModules,
|
|
59
|
+
requireFromCwd: () => requireFromCwd,
|
|
60
|
+
resolveFromCwd: () => resolveFromCwd,
|
|
61
|
+
resolvedMode: () => resolvedMode,
|
|
62
|
+
runEventSink: () => runEventSink,
|
|
63
|
+
runId: () => runId,
|
|
64
|
+
runStartedAt: () => runStartedAt,
|
|
65
|
+
sandbox: () => sandbox,
|
|
66
|
+
temporaryDirectory: () => temporaryDirectory,
|
|
67
|
+
testCoverage: () => testCoverage,
|
|
68
|
+
testRunnerConcurrencyTokens: () => testRunnerConcurrencyTokens,
|
|
69
|
+
testRunnerFactory: () => testRunnerFactory,
|
|
70
|
+
testRunnerPool: () => testRunnerPool,
|
|
71
|
+
timeOverheadMS: () => timeOverheadMS,
|
|
72
|
+
timer: () => timer,
|
|
73
|
+
unexpectedExitRegistry: () => unexpectedExitRegistry,
|
|
74
|
+
validationSchema: () => validationSchema,
|
|
75
|
+
workerIdGenerator: () => workerIdGenerator
|
|
76
|
+
});
|
|
77
|
+
const checkerPool = "checkerPool";
|
|
78
|
+
const checkerFactory = "checkerFactory";
|
|
79
|
+
const checkerConcurrencyTokens = "checkerConcurrencyTokens";
|
|
80
|
+
const disableTypeChecksHelper = "disableTypeChecksHelper";
|
|
81
|
+
const execa = "execa";
|
|
82
|
+
const execaSync = "execaSync";
|
|
83
|
+
const dryRunResult = "dryRunResult";
|
|
84
|
+
const mutants = "mutants";
|
|
85
|
+
const mutantTestPlanner = "mutantTestPlanner";
|
|
86
|
+
const process$1 = "process";
|
|
87
|
+
const pluginModulePaths = "pluginModulePaths";
|
|
88
|
+
const temporaryDirectory = "temporaryDirectory";
|
|
89
|
+
const unexpectedExitRegistry = "unexpectedExitRegistry";
|
|
90
|
+
const timer = "timer";
|
|
91
|
+
const timeOverheadMS = "timeOverheadMS";
|
|
92
|
+
const loggingServerAddress = "loggingServerAddress";
|
|
93
|
+
const loggerActiveLevel = "loggerActiveLevel";
|
|
94
|
+
const loggerConsoleOut = "loggerConsoleOut";
|
|
95
|
+
const loggerShowColors = "loggerShowColors";
|
|
96
|
+
const loggingSink = "loggingSink";
|
|
97
|
+
const runEventSink = "runEventSink";
|
|
98
|
+
const runId = "runId";
|
|
99
|
+
const resolvedMode = "resolvedMode";
|
|
100
|
+
const progressEnabled = "progressEnabled";
|
|
101
|
+
const clearTextEnabled = "clearTextEnabled";
|
|
102
|
+
const runStartedAt = "runStartedAt";
|
|
103
|
+
const reporterPluginModules = "reporterPluginModules";
|
|
104
|
+
const loggingServer = "loggingServer";
|
|
105
|
+
const mutationTestReportHelper = "mutationTestReportHelper";
|
|
106
|
+
const sandbox = "sandbox";
|
|
107
|
+
const concurrencyTokenProvider = "concurrencyTokenProvider";
|
|
108
|
+
const testRunnerFactory = "testRunnerFactory";
|
|
109
|
+
const testRunnerPool = "testRunnerPool";
|
|
110
|
+
const testRunnerConcurrencyTokens = "testRunnerConcurrencyTokens";
|
|
111
|
+
const reporter = "reporter";
|
|
112
|
+
const reporterOverride = "reporterOverride";
|
|
113
|
+
const pluginCreator = "pluginCreator";
|
|
114
|
+
const pluginsByKind = "pluginsByKind";
|
|
115
|
+
const validationSchema = "validationSchema";
|
|
116
|
+
const optionsValidator = "optionsValidator";
|
|
117
|
+
const requireFromCwd = "requireFromCwd";
|
|
118
|
+
const resolveFromCwd = "resolveFromCwd";
|
|
119
|
+
const testCoverage = "testCoverage";
|
|
120
|
+
const incrementalDiffer = "incrementalDiffer";
|
|
121
|
+
const project = "project";
|
|
122
|
+
const workerIdGenerator = "worker-id-generator";
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/plugins/plugin-creator.ts
|
|
125
|
+
var PluginCreator = class {
|
|
126
|
+
pluginsByKind;
|
|
127
|
+
injector;
|
|
128
|
+
static inject = tokens(pluginsByKind, commonTokens.injector);
|
|
129
|
+
constructor(pluginsByKind, injector) {
|
|
130
|
+
this.pluginsByKind = pluginsByKind;
|
|
131
|
+
this.injector = injector;
|
|
132
|
+
}
|
|
133
|
+
create(kind, name) {
|
|
134
|
+
const plugin = this.findPlugin(kind, name);
|
|
135
|
+
if (isFactoryPlugin(plugin)) return this.injector.injectFunction(plugin.factory);
|
|
136
|
+
else if (isClassPlugin(plugin)) return this.injector.injectClass(plugin.injectableClass);
|
|
137
|
+
else if (isValuePlugin(plugin)) return plugin.value;
|
|
138
|
+
throw new Error(`Plugin "${kind}:${name}" could not be created, missing "factory", "injectableClass" or "value" property.`);
|
|
139
|
+
}
|
|
140
|
+
findPlugin(kind, name) {
|
|
141
|
+
const plugins = this.pluginsByKind.get(kind);
|
|
142
|
+
if (plugins) {
|
|
143
|
+
const pluginFound = plugins.find((plugin) => plugin.name.toLowerCase() === name.toLowerCase());
|
|
144
|
+
if (pluginFound && isPluginOfKind(pluginFound, kind)) return pluginFound;
|
|
145
|
+
else throw new ConfigError(`Cannot find ${kind} plugin "${name}". Did you forget to install it? Loaded ${kind} plugins were: ${new Intl.ListFormat("en").format(plugins.map((p) => `"${p.name}"`))}`);
|
|
146
|
+
} else throw new ConfigError(`Cannot find ${kind} plugin "${name}". In fact, no ${kind} plugins were loaded. Did you forget to install it?`);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
function isPluginOfKind(plugin, kind) {
|
|
150
|
+
return plugin.kind === kind;
|
|
151
|
+
}
|
|
152
|
+
function isFactoryPlugin(plugin) {
|
|
153
|
+
return "factory" in plugin;
|
|
154
|
+
}
|
|
155
|
+
function isClassPlugin(plugin) {
|
|
156
|
+
return "injectableClass" in plugin;
|
|
157
|
+
}
|
|
158
|
+
function isValuePlugin(plugin) {
|
|
159
|
+
return "value" in plugin;
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/config/module-loader.ts
|
|
163
|
+
/**
|
|
164
|
+
* Wrapper around the 'import' expression (for testability)
|
|
165
|
+
* Resolves bare specifiers relative to cwd so plugins in the
|
|
166
|
+
* consumer's node_modules can be found even when the importing
|
|
167
|
+
* module lives at a different path in the monorepo.
|
|
168
|
+
*/
|
|
169
|
+
function importModule(moduleName) {
|
|
170
|
+
if (moduleName.startsWith(".") || moduleName.startsWith("/") || moduleName.startsWith("file://")) return import(moduleName);
|
|
171
|
+
return import(createRequire(process.cwd() + "/noop.js").resolve(moduleName));
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/project/file-system.ts
|
|
175
|
+
const MAX_CONCURRENT_FILE_IO = 256;
|
|
176
|
+
/**
|
|
177
|
+
* A wrapper around nodejs's 'fs' core module, for dependency injection purposes.
|
|
178
|
+
*
|
|
179
|
+
* Also has build-in buffering support with a concurrency limit (like "graceful-fs").
|
|
180
|
+
*/
|
|
181
|
+
var FileSystem = class {
|
|
182
|
+
/**
|
|
183
|
+
* The buffered work queue: each pending call is a zero-argument thunk that
|
|
184
|
+
* performs the real `fs.promises` call. `mergeMap` with the concurrency
|
|
185
|
+
* limit is the "graceful-fs" hat.
|
|
186
|
+
*/
|
|
187
|
+
todoSubject = new Subject();
|
|
188
|
+
subscription = this.todoSubject.pipe(mergeMap(async (work) => {
|
|
189
|
+
await work();
|
|
190
|
+
}, MAX_CONCURRENT_FILE_IO)).subscribe();
|
|
191
|
+
dispose() {
|
|
192
|
+
this.subscription.unsubscribe();
|
|
193
|
+
}
|
|
194
|
+
readFile(path, options) {
|
|
195
|
+
return this.queue(() => fs.promises.readFile(path, options));
|
|
196
|
+
}
|
|
197
|
+
copyFile(src, dest, mode) {
|
|
198
|
+
return this.queue(() => fs.promises.copyFile(src, dest, mode));
|
|
199
|
+
}
|
|
200
|
+
writeFile(path, data, options) {
|
|
201
|
+
return this.queue(() => fs.promises.writeFile(path, data, options));
|
|
202
|
+
}
|
|
203
|
+
mkdir(path, options) {
|
|
204
|
+
return this.queue(() => fs.promises.mkdir(path, options));
|
|
205
|
+
}
|
|
206
|
+
readdir(path, options) {
|
|
207
|
+
return this.queue(() => fs.promises.readdir(path, options));
|
|
208
|
+
}
|
|
209
|
+
/** Buffer one fs call behind the concurrency limit and await its outcome. */
|
|
210
|
+
queue(work) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
this.todoSubject.next(async () => {
|
|
213
|
+
try {
|
|
214
|
+
resolve(await work());
|
|
215
|
+
} catch (err) {
|
|
216
|
+
reject(err);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/project/project-file.ts
|
|
224
|
+
/**
|
|
225
|
+
* Represents a file inside the project under test.
|
|
226
|
+
* Has utility methods to help with copying it to the sandbox, or backing it up (when `--inPlace` is active)
|
|
227
|
+
*
|
|
228
|
+
* Assumes utf-8 as encoding when reading/writing content.
|
|
229
|
+
*/
|
|
230
|
+
var ProjectFile = class {
|
|
231
|
+
fs;
|
|
232
|
+
name;
|
|
233
|
+
mutate;
|
|
234
|
+
#currentContent;
|
|
235
|
+
#originalContent;
|
|
236
|
+
#relativePath;
|
|
237
|
+
constructor(fs, name, mutate) {
|
|
238
|
+
this.fs = fs;
|
|
239
|
+
this.name = name;
|
|
240
|
+
this.mutate = mutate;
|
|
241
|
+
this.#relativePath = path.relative(process.cwd(), this.name);
|
|
242
|
+
}
|
|
243
|
+
async #writeTo(to) {
|
|
244
|
+
if (this.#currentContent === void 0) await this.fs.copyFile(this.name, to);
|
|
245
|
+
else await this.fs.writeFile(to, this.#currentContent, "utf-8");
|
|
246
|
+
}
|
|
247
|
+
setContent(content) {
|
|
248
|
+
this.#currentContent = content;
|
|
249
|
+
}
|
|
250
|
+
async toInstrumenterFile() {
|
|
251
|
+
return {
|
|
252
|
+
content: await this.readContent(),
|
|
253
|
+
mutate: this.mutate,
|
|
254
|
+
name: this.name
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
async readContent() {
|
|
258
|
+
if (this.#currentContent === void 0) this.#currentContent = await this.readOriginal();
|
|
259
|
+
return this.#currentContent;
|
|
260
|
+
}
|
|
261
|
+
async readOriginal() {
|
|
262
|
+
if (this.#originalContent === void 0) {
|
|
263
|
+
try {
|
|
264
|
+
this.#originalContent = await this.fs.readFile(this.name, "utf-8");
|
|
265
|
+
} catch (e) {
|
|
266
|
+
throw new StrykerError(`Could not read file "${this.name}"`, e);
|
|
267
|
+
}
|
|
268
|
+
if (this.#currentContent === void 0) this.#currentContent = this.#originalContent;
|
|
269
|
+
}
|
|
270
|
+
return this.#originalContent;
|
|
271
|
+
}
|
|
272
|
+
async writeInPlace() {
|
|
273
|
+
if (this.#currentContent !== void 0 && this.hasChanges) await this.fs.writeFile(this.name, this.#currentContent, "utf-8");
|
|
274
|
+
}
|
|
275
|
+
async writeToSandbox(sandboxDir) {
|
|
276
|
+
const folderName = path.join(sandboxDir, path.dirname(this.#relativePath));
|
|
277
|
+
const targetFileName = path.join(folderName, path.basename(this.#relativePath));
|
|
278
|
+
await this.fs.mkdir(path.dirname(targetFileName), { recursive: true });
|
|
279
|
+
await this.#writeTo(targetFileName);
|
|
280
|
+
return targetFileName;
|
|
281
|
+
}
|
|
282
|
+
async backupTo(backupDir) {
|
|
283
|
+
const backupFileName = path.join(backupDir, this.#relativePath);
|
|
284
|
+
await this.fs.mkdir(path.dirname(backupFileName), { recursive: true });
|
|
285
|
+
if (this.#originalContent === void 0) await this.fs.copyFile(this.name, backupFileName);
|
|
286
|
+
else await this.fs.writeFile(backupFileName, this.#originalContent);
|
|
287
|
+
return backupFileName;
|
|
288
|
+
}
|
|
289
|
+
get hasChanges() {
|
|
290
|
+
return this.#currentContent !== this.#originalContent;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/config/config-file-formats.ts
|
|
295
|
+
const combine = (prefixes, suffixes, extensions) => {
|
|
296
|
+
const fileNames = [];
|
|
297
|
+
for (const prefix of prefixes) for (const suffix of suffixes) for (const extension of extensions) fileNames.push(`${prefix}stryker${suffix}.${extension}`);
|
|
298
|
+
return fileNames;
|
|
299
|
+
};
|
|
300
|
+
const SUPPORTED_CONFIG_FILE_NAMES = Object.freeze(combine(["", "."], [".conf", ".config"], [
|
|
301
|
+
"json",
|
|
302
|
+
"js",
|
|
303
|
+
"mjs",
|
|
304
|
+
"cjs"
|
|
305
|
+
]));
|
|
306
|
+
Object.freeze({
|
|
307
|
+
JSON: "stryker.config.json",
|
|
308
|
+
JAVASCRIPT: "stryker.config.mjs"
|
|
309
|
+
});
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/config/config-document.schema.ts
|
|
312
|
+
/**
|
|
313
|
+
* A Stryker config document read from disk or imported from a JS module: any
|
|
314
|
+
* object record. Individual option keys and value shapes are enforced later
|
|
315
|
+
* by the options validator against the derived JSON schema, so this boundary
|
|
316
|
+
* decode only establishes that the document is an object — never a
|
|
317
|
+
* hand-written `isRecord` narrowing.
|
|
318
|
+
*/
|
|
319
|
+
const ConfigDocumentSchema = S.Record(S.String, S.Unknown);
|
|
320
|
+
/**
|
|
321
|
+
* The shape of an imported JS config module as seen through the dynamic
|
|
322
|
+
* `import()` boundary: a module namespace whose `default` export is the
|
|
323
|
+
* config document. Only the default export is read here.
|
|
324
|
+
*/
|
|
325
|
+
const ImportedModuleSchema = S.Struct({ default: S.optional(S.Unknown) });
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/config/extends-step.ts
|
|
328
|
+
const initialExtendsStepState = {
|
|
329
|
+
visited: [],
|
|
330
|
+
documents: []
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Merge a child config over a parent's resolved options.
|
|
334
|
+
* R2: scalars replace wholesale; objects merge one level deep.
|
|
335
|
+
* R3: a child key set to `null` deletes the inherited key.
|
|
336
|
+
* R4: the `plugins` array is the one exception to wholesale array replacement —
|
|
337
|
+
* the parent's plugin loaders stay inherited and the child's descriptors are
|
|
338
|
+
* appended, with the first occurrence of a descriptor winning.
|
|
339
|
+
*
|
|
340
|
+
* A copy of the precedence logic in `resolve-extends.ts`, moved here with the
|
|
341
|
+
* original staying put. Where the original re-decoded each object through
|
|
342
|
+
* `ConfigDocumentSchema`, this copy spreads the validated objects directly:
|
|
343
|
+
* they already passed that schema at the read boundary, so the re-decode was
|
|
344
|
+
* dead weight and its throw site does not move into this module.
|
|
345
|
+
*/
|
|
346
|
+
function mergeConfigs(parent, child) {
|
|
347
|
+
const out = { ...parent };
|
|
348
|
+
for (const [key, value] of Object.entries(child)) {
|
|
349
|
+
if (value === null) {
|
|
350
|
+
delete out[key];
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const parentValue = parent[key];
|
|
354
|
+
if (key === "plugins") {
|
|
355
|
+
const parentPlugins = Array.isArray(parentValue) ? parentValue : [];
|
|
356
|
+
const childPlugins = Array.isArray(value) ? value : [];
|
|
357
|
+
const merged = [...parentPlugins, ...childPlugins];
|
|
358
|
+
out[key] = merged.filter((descriptor, index) => typeof descriptor !== "string" || !merged.slice(0, index).includes(descriptor));
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
out[key] = parentValue !== null && parentValue !== void 0 && typeof parentValue === "object" && !Array.isArray(parentValue) && typeof value === "object" && !Array.isArray(value) ? {
|
|
362
|
+
...parentValue,
|
|
363
|
+
...value
|
|
364
|
+
} : value;
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A bare package specifier (`pkg`, `@scope/pkg`, `@scope/pkg/sub`) versus a
|
|
370
|
+
* filesystem path. Everything not starting with `./`, `../`, `/` or `\` is
|
|
371
|
+
* treated as a specifier and routed through the Node resolver, so it honours
|
|
372
|
+
* `package.json#exports` the way `@systemfsoftware/tsconfig` does for
|
|
373
|
+
* `tsconfig.json`.
|
|
374
|
+
*/
|
|
375
|
+
function isModuleSpecifier(value) {
|
|
376
|
+
return !(value.startsWith("./") || value.startsWith("../") || value.startsWith("/") || value.startsWith("\\"));
|
|
377
|
+
}
|
|
378
|
+
const stripExtends = (document) => {
|
|
379
|
+
const { extends: _ignored, ...rest } = document;
|
|
380
|
+
return rest;
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Fold the accumulated chain bottom-up, exactly the order `resolveExtendsChain`
|
|
384
|
+
* merged in: the root document first, its child over it, and so on up to the
|
|
385
|
+
* document this step was asked to decide.
|
|
386
|
+
*/
|
|
387
|
+
const mergeChainDocuments = (documents) => documents.reduceRight((merged, entry) => mergeConfigs(merged, stripExtends(entry.options)), {});
|
|
388
|
+
/**
|
|
389
|
+
* Decide the next act in an `extends` chain. Receives the accumulated state
|
|
390
|
+
* plus the last document read and the absolute path it was read from, and
|
|
391
|
+
* returns exactly one of:
|
|
392
|
+
*
|
|
393
|
+
* - `done` — no further parent: the whole chain is merged and carried.
|
|
394
|
+
* - `resolve` — a bare specifier the shell must resolve from `directory`.
|
|
395
|
+
* - `read` — a relative path, already resolved against the declaring
|
|
396
|
+
* document's directory (never the process working directory).
|
|
397
|
+
* - `refused` — a cycle (the path is already visited) or an `extends` that is
|
|
398
|
+
* not a string, naming the offending file.
|
|
399
|
+
*
|
|
400
|
+
* The request performs no read and no resolution; it is data the shell
|
|
401
|
+
* performs and feeds back.
|
|
402
|
+
*/
|
|
403
|
+
const decideExtendsStep = (state, document, file) => {
|
|
404
|
+
if (state.visited.includes(file)) return {
|
|
405
|
+
_tag: "refused",
|
|
406
|
+
reason: "cycle",
|
|
407
|
+
file
|
|
408
|
+
};
|
|
409
|
+
const nextState = {
|
|
410
|
+
visited: [...state.visited, file],
|
|
411
|
+
documents: [...state.documents, {
|
|
412
|
+
path: file,
|
|
413
|
+
options: document
|
|
414
|
+
}]
|
|
415
|
+
};
|
|
416
|
+
return Match$1.value(document["extends"]).pipe(Match$1.when(void 0, () => ({
|
|
417
|
+
_tag: "done",
|
|
418
|
+
options: mergeChainDocuments(nextState.documents)
|
|
419
|
+
})), Match$1.when(null, () => ({
|
|
420
|
+
_tag: "done",
|
|
421
|
+
options: mergeChainDocuments(nextState.documents)
|
|
422
|
+
})), Match$1.when(Match$1.string, (extendValue) => Match$1.value(isModuleSpecifier(extendValue)).pipe(Match$1.when(true, () => ({
|
|
423
|
+
_tag: "resolve",
|
|
424
|
+
specifier: extendValue,
|
|
425
|
+
directory: path.dirname(file),
|
|
426
|
+
state: nextState
|
|
427
|
+
})), Match$1.when(false, () => ({
|
|
428
|
+
_tag: "read",
|
|
429
|
+
path: path.resolve(path.dirname(file), extendValue),
|
|
430
|
+
state: nextState
|
|
431
|
+
})), Match$1.exhaustive)), Match$1.orElse(() => ({
|
|
432
|
+
_tag: "refused",
|
|
433
|
+
reason: "non-string-extends",
|
|
434
|
+
file
|
|
435
|
+
})));
|
|
436
|
+
};
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/config/resolve-extends.ts
|
|
439
|
+
async function readConfigFile(configFile) {
|
|
440
|
+
if (path.extname(configFile).toLowerCase() === ".json") {
|
|
441
|
+
let fileContent;
|
|
442
|
+
try {
|
|
443
|
+
fileContent = await fs.promises.readFile(configFile, "utf-8");
|
|
444
|
+
} catch (err) {
|
|
445
|
+
throw new ConfigError(`Cannot read config file "${configFile}"`, err);
|
|
446
|
+
}
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(fileContent);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
throw new ConfigError(`Invalid config file "${configFile}". File contains invalid JSON`, err);
|
|
452
|
+
}
|
|
453
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`Invalid config file "${configFile}". Config must be a JSON object`);
|
|
454
|
+
return S.decodeUnknownSync(ConfigDocumentSchema)(parsed);
|
|
455
|
+
}
|
|
456
|
+
let importedModule;
|
|
457
|
+
try {
|
|
458
|
+
importedModule = await import(pathToFileURL(path.resolve(configFile)).toString());
|
|
459
|
+
} catch (err) {
|
|
460
|
+
throw new ConfigError(`Invalid config file "${configFile}". Error during import`, err);
|
|
461
|
+
}
|
|
462
|
+
const exported = S.decodeUnknownSync(ImportedModuleSchema)(importedModule).default;
|
|
463
|
+
if (exported === void 0 || exported === null || typeof exported !== "object") throw new ConfigError(`Invalid config file "${configFile}". Default export of config file must be an object!`);
|
|
464
|
+
return S.decodeUnknownSync(ConfigDocumentSchema)(exported);
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The resolve act, split out of the deleted `resolveExtendsTarget`. Its
|
|
468
|
+
* relative-path branch became the decision's `read` request; only this branch
|
|
469
|
+
* survives, converting a bare specifier to an absolute path through node's
|
|
470
|
+
* require resolver from the declaring config's directory — never the process
|
|
471
|
+
* working directory (R10). The error message is the pre-split one, verbatim.
|
|
472
|
+
*/
|
|
473
|
+
function resolveExtendsSpecifier(specifier, configDir) {
|
|
474
|
+
const requireFrom = createRequire(path.join(configDir, "noop.js"));
|
|
475
|
+
try {
|
|
476
|
+
return requireFrom.resolve(specifier);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
throw new ConfigError(`Cannot resolve extends target "${specifier}" from "${configDir}"`, err);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Interpret the `extends` decision at the shell boundary (R9, KTD2).
|
|
483
|
+
*
|
|
484
|
+
* Drives `decideExtendsStep` in a plain async loop. The entry document is
|
|
485
|
+
* already read by the caller; each returned request names the act the shell
|
|
486
|
+
* performs — read this absolute path, or resolve this specifier from the
|
|
487
|
+
* declaring directory — and the document the act yields is fed back together
|
|
488
|
+
* with the state the request carried. The shell holds no decision of its own:
|
|
489
|
+
* it only dispatches on the request it received and maps a refusal to the
|
|
490
|
+
* `ConfigError` the pre-split chain surfaced, byte for byte.
|
|
491
|
+
*/
|
|
492
|
+
async function resolveExtends(configFile, document) {
|
|
493
|
+
const absolute = path.resolve(configFile);
|
|
494
|
+
let state = initialExtendsStepState;
|
|
495
|
+
let file = absolute;
|
|
496
|
+
let currentDocument = document;
|
|
497
|
+
for (;;) {
|
|
498
|
+
const outcome = await Match.value(decideExtendsStep(state, currentDocument, file)).pipe(Match.tag("done", (value) => ({
|
|
499
|
+
kind: "done",
|
|
500
|
+
options: value.options
|
|
501
|
+
})), Match.tag("read", (value) => readConfigFile(value.path).then((nextDocument) => ({
|
|
502
|
+
kind: "next",
|
|
503
|
+
state: value.state,
|
|
504
|
+
file: value.path,
|
|
505
|
+
document: nextDocument
|
|
506
|
+
}))), Match.tag("resolve", (value) => {
|
|
507
|
+
let resolvedPath;
|
|
508
|
+
try {
|
|
509
|
+
resolvedPath = resolveExtendsSpecifier(value.specifier, value.directory);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if (err instanceof ConfigError) throw err;
|
|
512
|
+
const reason = err instanceof Error ? `. ${err.message}` : "";
|
|
513
|
+
throw new ConfigError(`Cannot resolve extends target "${value.specifier}" from "${file}"${reason}`, err);
|
|
514
|
+
}
|
|
515
|
+
return readConfigFile(resolvedPath).then((nextDocument) => ({
|
|
516
|
+
kind: "next",
|
|
517
|
+
state: value.state,
|
|
518
|
+
file: resolvedPath,
|
|
519
|
+
document: nextDocument
|
|
520
|
+
}));
|
|
521
|
+
}), Match.tag("refused", (value) => {
|
|
522
|
+
const message = value.reason === "cycle" ? `Config inheritance cycle detected at "${value.file}"` : `Invalid config file "${value.file}". "extends" must be a string`;
|
|
523
|
+
throw new ConfigError(message);
|
|
524
|
+
}), Match.exhaustive);
|
|
525
|
+
if (outcome.kind === "done") return outcome.options;
|
|
526
|
+
state = outcome.state;
|
|
527
|
+
file = outcome.file;
|
|
528
|
+
currentDocument = outcome.document;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/config/config-reader.ts
|
|
533
|
+
const CONFIG_SYNTAX_HELP = `
|
|
534
|
+
Example of how a config file should look:
|
|
535
|
+
/**
|
|
536
|
+
* @type {import('@systemfsoftware/stryker-js-plugin-api/core').StrykerOptions}
|
|
537
|
+
*/
|
|
538
|
+
export default {
|
|
539
|
+
// You're options here!
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
Or using commonjs:
|
|
543
|
+
/**
|
|
544
|
+
* @type {import('@systemfsoftware/stryker-js-plugin-api/core').StrykerOptions}
|
|
545
|
+
*/
|
|
546
|
+
module.exports = {
|
|
547
|
+
// You're options here!
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
See https://stryker-mutator.io/docs/stryker-js/config-file for more information.`.trim();
|
|
551
|
+
async function exists(fileName) {
|
|
552
|
+
try {
|
|
553
|
+
await fs.promises.access(fileName);
|
|
554
|
+
return true;
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (isErrnoException(err) && err.code === "ENOENT") return false;
|
|
557
|
+
else throw err;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
var ConfigReader = class {
|
|
561
|
+
log;
|
|
562
|
+
validator;
|
|
563
|
+
static inject = tokens(commonTokens.logger, optionsValidator);
|
|
564
|
+
constructor(log, validator) {
|
|
565
|
+
this.log = log;
|
|
566
|
+
this.validator = validator;
|
|
567
|
+
}
|
|
568
|
+
async readConfig(cliOptions) {
|
|
569
|
+
const options = await this.loadOptionsFromConfigFile(cliOptions);
|
|
570
|
+
deepMerge(options, cliOptions);
|
|
571
|
+
this.validator.validate(options);
|
|
572
|
+
if (this.log.isDebugEnabled()) this.log.debug(`Loaded config: ${JSON.stringify(options, null, 2)}`);
|
|
573
|
+
return options;
|
|
574
|
+
}
|
|
575
|
+
async loadOptionsFromConfigFile(cliOptions) {
|
|
576
|
+
const configFile = await this.findConfigFile(cliOptions["configFile"]);
|
|
577
|
+
if (!configFile) {
|
|
578
|
+
this.log.info("No config file specified. Running with command line arguments.");
|
|
579
|
+
this.log.info("Use `stryker init` command to generate your config file.");
|
|
580
|
+
return {};
|
|
581
|
+
}
|
|
582
|
+
this.log.debug(`Loading config from ${configFile}`);
|
|
583
|
+
const child = path.extname(configFile).toLocaleLowerCase() === ".json" ? await this.readJsonConfig(configFile) : await this.importJSConfig(configFile);
|
|
584
|
+
if (!("extends" in child)) return child;
|
|
585
|
+
return resolveExtends(configFile, child);
|
|
586
|
+
}
|
|
587
|
+
async findConfigFile(configFileName) {
|
|
588
|
+
if (typeof configFileName === "string") {
|
|
589
|
+
if (await exists(configFileName)) return configFileName;
|
|
590
|
+
else throw new ConfigReaderError("File does not exist!", configFileName);
|
|
591
|
+
}
|
|
592
|
+
for (const fileName of SUPPORTED_CONFIG_FILE_NAMES) if (await exists(fileName)) return fileName;
|
|
593
|
+
}
|
|
594
|
+
async readJsonConfig(configFile) {
|
|
595
|
+
const fileContent = await fs.promises.readFile(configFile, "utf-8");
|
|
596
|
+
try {
|
|
597
|
+
const parsed = JSON.parse(fileContent);
|
|
598
|
+
return S.decodeUnknownSync(S.Record(S.String, S.Unknown))(parsed);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
throw new ConfigReaderError("File contains invalid JSON", configFile, err);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
async importJSConfig(configFile) {
|
|
604
|
+
const importedModule = await this.importJSConfigModule(configFile);
|
|
605
|
+
if (this.hasDefaultExport(importedModule)) {
|
|
606
|
+
const maybeOptions = importedModule.default;
|
|
607
|
+
if (typeof maybeOptions !== "object") {
|
|
608
|
+
if (typeof maybeOptions === "function") this.log.fatal(`Invalid config file. Exporting a function is no longer supported. Please export an object with your configuration instead, or use a "stryker.conf.json" file.\n${CONFIG_SYNTAX_HELP}`);
|
|
609
|
+
else this.log.fatal(`Invalid config file. It must export an object, found a "${typeof maybeOptions}"!\n${CONFIG_SYNTAX_HELP}`);
|
|
610
|
+
throw new ConfigReaderError("Default export of config file must be an object!", configFile);
|
|
611
|
+
}
|
|
612
|
+
if (!maybeOptions || !Object.keys(maybeOptions).length) this.log.warn(`Stryker options were empty. Did you forget to export options from ${configFile}?`);
|
|
613
|
+
return { ...maybeOptions };
|
|
614
|
+
} else {
|
|
615
|
+
this.log.fatal(`Invalid config file. It is missing a default export. ${describeNamedExports()}\n${CONFIG_SYNTAX_HELP}`);
|
|
616
|
+
throw new ConfigReaderError("Config file must have a default export!", configFile);
|
|
617
|
+
function describeNamedExports() {
|
|
618
|
+
const namedExports = typeof importedModule === "object" && Object.keys(importedModule ?? {}) || [];
|
|
619
|
+
if (namedExports.length === 0) return "In fact, it didn't export anything.";
|
|
620
|
+
else return `Found named export(s): ${new Intl.ListFormat("en").format(namedExports.map((name) => `"${name}"`))}.`;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async importJSConfigModule(configFile) {
|
|
625
|
+
try {
|
|
626
|
+
return await importModule(pathToFileURL(path.resolve(configFile)).toString());
|
|
627
|
+
} catch (err) {
|
|
628
|
+
throw new ConfigReaderError("Error during import", configFile, err);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
hasDefaultExport(importedModule) {
|
|
632
|
+
return importedModule && typeof importedModule === "object" && "default" in importedModule ? true : false;
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
var ConfigReaderError = class extends ConfigError {
|
|
636
|
+
constructor(message, configFileName, cause) {
|
|
637
|
+
super(`Invalid config file "${configFileName}". ${message}`, cause);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/config/file-matcher.ts
|
|
642
|
+
/**
|
|
643
|
+
* A helper class for matching files using the `disableTypeChecks` setting.
|
|
644
|
+
*/
|
|
645
|
+
var FileMatcher = class {
|
|
646
|
+
allowHiddenFiles;
|
|
647
|
+
pattern;
|
|
648
|
+
constructor(pattern, allowHiddenFiles = true) {
|
|
649
|
+
this.allowHiddenFiles = allowHiddenFiles;
|
|
650
|
+
if (typeof pattern === "string") this.pattern = normalizeFileName(path.resolve(pattern));
|
|
651
|
+
else if (pattern) this.pattern = "**/*.{js,ts,jsx,tsx,html,vue,mjs,mts,cts,cjs}";
|
|
652
|
+
else this.pattern = pattern;
|
|
653
|
+
}
|
|
654
|
+
matches(fileName) {
|
|
655
|
+
if (typeof this.pattern === "string") return minimatch(normalizeFileName(path.resolve(fileName)), this.pattern, { dot: this.allowHiddenFiles });
|
|
656
|
+
else return this.pattern;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/config/meta-schema-builder.ts
|
|
661
|
+
var MetaSchemaBuilder = class {
|
|
662
|
+
schema;
|
|
663
|
+
log;
|
|
664
|
+
static inject = tokens(validationSchema, commonTokens.logger);
|
|
665
|
+
constructor(schema, log) {
|
|
666
|
+
this.schema = schema;
|
|
667
|
+
this.log = log;
|
|
668
|
+
}
|
|
669
|
+
buildMetaSchema(pluginSchemaContributions) {
|
|
670
|
+
this.log.debug("Contributing %s schemas from plugins to options validation.", pluginSchemaContributions.length);
|
|
671
|
+
return mergedSchema(this.schema, pluginSchemaContributions);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
function mergedSchema(mainSchema, additionalSchemas) {
|
|
675
|
+
const schema = {
|
|
676
|
+
...mainSchema,
|
|
677
|
+
properties: { ...mainSchema.properties },
|
|
678
|
+
definitions: { ...mainSchema.definitions }
|
|
679
|
+
};
|
|
680
|
+
Object.assign(schema.properties, ...additionalSchemas.map((s) => s.properties));
|
|
681
|
+
Object.assign(schema.definitions, ...additionalSchemas.map((s) => s.definitions));
|
|
682
|
+
return schema;
|
|
683
|
+
}
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/project/incremental-report.schema.ts
|
|
686
|
+
const PositionSchema = S.Struct({
|
|
687
|
+
line: S.Finite,
|
|
688
|
+
column: S.Finite
|
|
689
|
+
});
|
|
690
|
+
const LocationSchema = S.Struct({
|
|
691
|
+
start: PositionSchema,
|
|
692
|
+
end: PositionSchema
|
|
693
|
+
});
|
|
694
|
+
const OpenEndLocationSchema = S.Struct({
|
|
695
|
+
start: PositionSchema,
|
|
696
|
+
end: S.optionalKey(PositionSchema)
|
|
697
|
+
});
|
|
698
|
+
/** A report mutant: the full named surface of `MutantResult`. */
|
|
699
|
+
const MutantResultLikeSchema = S.Struct({
|
|
700
|
+
id: S.String,
|
|
701
|
+
mutatorName: S.String,
|
|
702
|
+
replacement: S.String,
|
|
703
|
+
location: LocationSchema,
|
|
704
|
+
status: S.Literals([
|
|
705
|
+
"Killed",
|
|
706
|
+
"Survived",
|
|
707
|
+
"NoCoverage",
|
|
708
|
+
"Timeout",
|
|
709
|
+
"CompileError",
|
|
710
|
+
"RuntimeError",
|
|
711
|
+
"Ignored",
|
|
712
|
+
"Pending"
|
|
713
|
+
]),
|
|
714
|
+
killedBy: S.optionalKey(S.mutable(S.Array(S.String))),
|
|
715
|
+
coveredBy: S.optionalKey(S.mutable(S.Array(S.String))),
|
|
716
|
+
static: S.optionalKey(S.Boolean),
|
|
717
|
+
statusReason: S.optionalKey(S.String),
|
|
718
|
+
testsCompleted: S.optionalKey(S.Finite),
|
|
719
|
+
description: S.optionalKey(S.String),
|
|
720
|
+
duration: S.optionalKey(S.Finite)
|
|
721
|
+
});
|
|
722
|
+
/** A report file entry. */
|
|
723
|
+
const FileResultLikeSchema = S.Struct({
|
|
724
|
+
language: S.String,
|
|
725
|
+
source: S.String,
|
|
726
|
+
mutants: S.mutable(S.Array(MutantResultLikeSchema))
|
|
727
|
+
});
|
|
728
|
+
/** A report test entry. */
|
|
729
|
+
const TestDefinitionLikeSchema = S.Struct({
|
|
730
|
+
id: S.String,
|
|
731
|
+
name: S.String,
|
|
732
|
+
location: S.optionalKey(OpenEndLocationSchema)
|
|
733
|
+
});
|
|
734
|
+
/** A report test file. */
|
|
735
|
+
const TestFileLikeSchema = S.Struct({
|
|
736
|
+
source: S.optionalKey(S.String),
|
|
737
|
+
tests: S.mutable(S.Array(TestDefinitionLikeSchema))
|
|
738
|
+
});
|
|
739
|
+
const ThresholdsLikeSchema = S.Struct({
|
|
740
|
+
high: S.Finite,
|
|
741
|
+
low: S.Finite
|
|
742
|
+
});
|
|
743
|
+
/**
|
|
744
|
+
* The prior-report document the incremental differ reuses. An open struct:
|
|
745
|
+
* every key the reader does not reshape is preserved verbatim. The report is
|
|
746
|
+
* data from disk, so it is decoded through this schema rather than trusted.
|
|
747
|
+
* `schemaVersion` and `thresholds` are declared — not just passed through the
|
|
748
|
+
* open rest — because the reshaped document is returned as a
|
|
749
|
+
* `MutationTestResult`, whose type names them.
|
|
750
|
+
*/
|
|
751
|
+
const IncrementalReportSchema = S.StructWithRest(S.Struct({
|
|
752
|
+
schemaVersion: S.String,
|
|
753
|
+
thresholds: ThresholdsLikeSchema,
|
|
754
|
+
files: S.Record(S.String, FileResultLikeSchema),
|
|
755
|
+
testFiles: S.optionalKey(S.Record(S.String, TestFileLikeSchema))
|
|
756
|
+
}), [S.Record(S.String, S.Unknown)]);
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/project/project.ts
|
|
759
|
+
/**
|
|
760
|
+
* Represents the project that is under test by Stryker users.
|
|
761
|
+
* This represents the files in the current working directory.
|
|
762
|
+
* Each file can be read into memory when needed (via `readContent`)
|
|
763
|
+
*/
|
|
764
|
+
var Project = class {
|
|
765
|
+
fileDescriptions;
|
|
766
|
+
incrementalReport;
|
|
767
|
+
files = /* @__PURE__ */ new Map();
|
|
768
|
+
filesToMutate = /* @__PURE__ */ new Map();
|
|
769
|
+
testFiles;
|
|
770
|
+
constructor(fs, fileDescriptions, incrementalReport, testFiles = []) {
|
|
771
|
+
this.fileDescriptions = fileDescriptions;
|
|
772
|
+
this.incrementalReport = incrementalReport;
|
|
773
|
+
this.testFiles = testFiles;
|
|
774
|
+
Object.entries(fileDescriptions).forEach(([name, desc]) => {
|
|
775
|
+
const file = new ProjectFile(fs, name, desc.mutate);
|
|
776
|
+
this.files.set(name, file);
|
|
777
|
+
if (desc.mutate) this.filesToMutate.set(name, file);
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
get isEmpty() {
|
|
781
|
+
return this.files.size === 0;
|
|
782
|
+
}
|
|
783
|
+
logFiles(log, ignoreRules, force, mutatePatterns, testFilePatterns = []) {
|
|
784
|
+
if (this.isEmpty) log.warn(normalizeWhitespaces(`No files found in directory ${process.cwd()} using ignore rules: ${JSON.stringify(ignoreRules)}.
|
|
785
|
+
Make sure you run Stryker from the root directory of your project with the correct "${propertyPath()("ignorePatterns")}".`));
|
|
786
|
+
else {
|
|
787
|
+
if (this.filesToMutate.size) {
|
|
788
|
+
const incrementalInfo = this.incrementalReport ? ` using incremental report with ${Object.values(this.incrementalReport.files).reduce((total, { mutants }) => total + mutants.length, 0)} mutant(s), and ${Object.values(this.incrementalReport.testFiles ?? {}).reduce((total, { tests }) => total + tests.length, 0)} test(s)${force ? ". Force mode is activated, all mutants will be retested" : ""}` : "";
|
|
789
|
+
log.info(`Found ${this.filesToMutate.size} of ${this.files.size} file(s) to be mutated${incrementalInfo}.`);
|
|
790
|
+
} else {
|
|
791
|
+
const msg = normalizeWhitespaces(`Warning: No files found for mutation with the given glob expressions. As a result, a dry-run will be performed without actually modifying anything.
|
|
792
|
+
If you intended to mutate files, please check and adjust the configuration.
|
|
793
|
+
Current glob pattern(s) used:
|
|
794
|
+
${new Intl.ListFormat("en").format(mutatePatterns.map((pattern) => `"${pattern}"`))}.
|
|
795
|
+
|
|
796
|
+
To enable file mutation, consider configuring the \`${propertyPath()("mutate")}\` property in your configuration file or using the --mutate option via the command line.`);
|
|
797
|
+
log.warn(msg);
|
|
798
|
+
}
|
|
799
|
+
if (this.testFiles.length > 0) log.info(`Found ${this.testFiles.length} test file(s) matching --testFiles patterns.`);
|
|
800
|
+
if (log.isDebugEnabled()) {
|
|
801
|
+
log.debug(`All input files: ${JSON.stringify([...this.files.keys()], null, 2)}`);
|
|
802
|
+
log.debug(`Files to mutate: ${JSON.stringify([...this.filesToMutate.keys()], null, 2)}`);
|
|
803
|
+
if (this.testFiles.length > 0) log.debug(`Test files: ${JSON.stringify(this.testFiles, null, 2)}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/project/project-reader.ts
|
|
810
|
+
const ALWAYS_IGNORE = Object.freeze([
|
|
811
|
+
"node_modules",
|
|
812
|
+
".git",
|
|
813
|
+
"*.tsbuildinfo",
|
|
814
|
+
"/stryker.log",
|
|
815
|
+
".next",
|
|
816
|
+
".nuxt",
|
|
817
|
+
".svelte-kit"
|
|
818
|
+
]);
|
|
819
|
+
/**
|
|
820
|
+
* @see https://stryker-mutator.io/docs/stryker-js/configuration/#mutate-string
|
|
821
|
+
* @example
|
|
822
|
+
* * "src/app.js:1-11" will mutate lines 1 through 11 inside app.js.
|
|
823
|
+
* * "src/app.js:5:4-6:4" will mutate from line 5, column 4 through line 6 column 4 inside app.js (columns 4 are included).
|
|
824
|
+
* * "src/app.js:5-6:4" will mutate from line 5, column 0 through line 6 column 4 inside app.js (column 4 is included).
|
|
825
|
+
*/
|
|
826
|
+
const MUTATION_RANGE_REGEX = /(.*?):((\d+)(?::(\d+))?-(\d+)(?::(\d+))?)$/;
|
|
827
|
+
var ProjectReader = class {
|
|
828
|
+
fs;
|
|
829
|
+
log;
|
|
830
|
+
mutatePatterns;
|
|
831
|
+
testFilePatterns;
|
|
832
|
+
ignoreRules;
|
|
833
|
+
incremental;
|
|
834
|
+
force;
|
|
835
|
+
incrementalFile;
|
|
836
|
+
static inject = tokens("fs", commonTokens.logger, commonTokens.options);
|
|
837
|
+
constructor(fs, log, { mutate, tempDirName, ignorePatterns, incremental, incrementalFile, force, htmlReporter, jsonReporter, testFiles }) {
|
|
838
|
+
this.fs = fs;
|
|
839
|
+
this.log = log;
|
|
840
|
+
this.mutatePatterns = mutate;
|
|
841
|
+
this.testFilePatterns = testFiles ?? [];
|
|
842
|
+
this.ignoreRules = [
|
|
843
|
+
...ALWAYS_IGNORE,
|
|
844
|
+
tempDirName,
|
|
845
|
+
incrementalFile,
|
|
846
|
+
htmlReporter.fileName,
|
|
847
|
+
jsonReporter.fileName,
|
|
848
|
+
...ignorePatterns
|
|
849
|
+
];
|
|
850
|
+
this.incremental = incremental;
|
|
851
|
+
this.incrementalFile = incrementalFile;
|
|
852
|
+
this.force = force;
|
|
853
|
+
}
|
|
854
|
+
async read(targetMutatePatterns) {
|
|
855
|
+
const inputFileNames = await this.resolveInputFileNames();
|
|
856
|
+
const fileDescriptions = this.resolveFileDescriptions(inputFileNames, targetMutatePatterns);
|
|
857
|
+
const testFiles = this.resolveTestFiles(inputFileNames);
|
|
858
|
+
const project = new Project(this.fs, fileDescriptions, await this.readIncrementalReport(), testFiles);
|
|
859
|
+
project.logFiles(this.log, this.ignoreRules, this.force, this.mutatePatterns, this.testFilePatterns);
|
|
860
|
+
return project;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Resolves test file patterns to actual file paths.
|
|
864
|
+
* @param inputFileNames The list of all input file names to filter
|
|
865
|
+
* @returns Array of resolved test file paths
|
|
866
|
+
*/
|
|
867
|
+
resolveTestFiles(inputFileNames) {
|
|
868
|
+
if (this.testFilePatterns.length === 0) return [];
|
|
869
|
+
const resolvedTestFiles = [];
|
|
870
|
+
for (const pattern of this.testFilePatterns) {
|
|
871
|
+
const matcher = new FileMatcher(pattern, false);
|
|
872
|
+
const matchedFiles = inputFileNames.filter((fileName) => matcher.matches(fileName));
|
|
873
|
+
if (matchedFiles.length === 0) this.log.warn(`Glob pattern "${pattern}" did not match any test files.`);
|
|
874
|
+
resolvedTestFiles.push(...matchedFiles);
|
|
875
|
+
}
|
|
876
|
+
return [...new Set(resolvedTestFiles)];
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Takes the list of file names and creates file description object from it, containing logic about wether or not it needs to be mutated.
|
|
880
|
+
* If a mutate pattern starts with a `!`, it negates the pattern.
|
|
881
|
+
* @param inputFileNames the file names to filter
|
|
882
|
+
* @param targetMutatePatterns optional mutate patterns to limit the initial scope of files to mutate (with ranges)
|
|
883
|
+
*/
|
|
884
|
+
resolveFileDescriptions(inputFileNames, targetMutatePatterns) {
|
|
885
|
+
const logAboutUselessPatterns = !isDeepStrictEqual(this.mutatePatterns, defaultOptions.mutate);
|
|
886
|
+
const mutateInputFileMap = /* @__PURE__ */ new Map();
|
|
887
|
+
inputFileNames.forEach((fileName) => mutateInputFileMap.set(fileName, { mutate: false }));
|
|
888
|
+
for (const pattern of this.mutatePatterns) if (pattern.startsWith("!")) {
|
|
889
|
+
const files = this.filterMutatePattern(mutateInputFileMap.keys(), pattern.substring(1));
|
|
890
|
+
if (logAboutUselessPatterns && files.size === 0) this.log.warn(`Glob pattern "${pattern}" did not exclude any files.`);
|
|
891
|
+
for (const fileName of files.keys()) mutateInputFileMap.set(fileName, { mutate: false });
|
|
892
|
+
} else {
|
|
893
|
+
const files = this.filterMutatePattern(inputFileNames, pattern);
|
|
894
|
+
if (logAboutUselessPatterns && files.size === 0) this.log.warn(`Glob pattern "${pattern}" did not result in any files.`);
|
|
895
|
+
for (const [fileName, file] of files) mutateInputFileMap.set(fileName, this.unionFileDescriptions(file, mutateInputFileMap.get(fileName)));
|
|
896
|
+
}
|
|
897
|
+
if (targetMutatePatterns) {
|
|
898
|
+
const seen = /* @__PURE__ */ new Map();
|
|
899
|
+
for (const pattern of targetMutatePatterns) {
|
|
900
|
+
const files = this.filterMutatePattern(mutateInputFileMap.keys(), pattern);
|
|
901
|
+
for (const [fileName, description] of files) {
|
|
902
|
+
const current = mutateInputFileMap.get(fileName);
|
|
903
|
+
if (current === void 0) continue;
|
|
904
|
+
const intersected = this.intersectFileDescriptions(current, description);
|
|
905
|
+
seen.set(fileName, this.unionFileDescriptions(intersected, seen.get(fileName)));
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
for (const fileName of mutateInputFileMap.keys()) {
|
|
909
|
+
const descriptionInSeen = seen.get(fileName);
|
|
910
|
+
if (descriptionInSeen) mutateInputFileMap.set(fileName, descriptionInSeen);
|
|
911
|
+
else mutateInputFileMap.set(fileName, { mutate: false });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return Object.fromEntries(mutateInputFileMap);
|
|
915
|
+
}
|
|
916
|
+
unionFileDescriptions(first, second) {
|
|
917
|
+
if (second) {
|
|
918
|
+
if (Array.isArray(first.mutate) && Array.isArray(second.mutate)) return { mutate: [...second.mutate, ...first.mutate] };
|
|
919
|
+
else if (second.mutate === true) return { mutate: true };
|
|
920
|
+
return { mutate: first.mutate || second.mutate };
|
|
921
|
+
}
|
|
922
|
+
return first;
|
|
923
|
+
}
|
|
924
|
+
intersectFileDescriptions(first, second) {
|
|
925
|
+
if (Array.isArray(first.mutate) && Array.isArray(second.mutate)) {
|
|
926
|
+
const secondMutate = second.mutate;
|
|
927
|
+
return { mutate: first.mutate.flatMap((firstRange) => secondMutate.map((secondRange) => {
|
|
928
|
+
const startLine = Math.max(firstRange.start.line, secondRange.start.line);
|
|
929
|
+
const endLine = Math.min(firstRange.end.line, secondRange.end.line);
|
|
930
|
+
if (startLine > endLine) return;
|
|
931
|
+
const startColumn = firstRange.start.line === startLine ? firstRange.start.column : secondRange.start.column;
|
|
932
|
+
const endColumn = firstRange.end.line === endLine ? firstRange.end.column : secondRange.end.column;
|
|
933
|
+
return {
|
|
934
|
+
start: {
|
|
935
|
+
line: startLine,
|
|
936
|
+
column: startColumn
|
|
937
|
+
},
|
|
938
|
+
end: {
|
|
939
|
+
line: endLine,
|
|
940
|
+
column: endColumn
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
})).filter(notEmpty) };
|
|
944
|
+
} else if (first.mutate === true) return second;
|
|
945
|
+
else if (second.mutate === true) return first;
|
|
946
|
+
return { mutate: false };
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Filters a given list of file names given a mutate pattern.
|
|
950
|
+
* @param fileNames the file names to match to the pattern
|
|
951
|
+
* @param mutatePattern the pattern to match with
|
|
952
|
+
*/
|
|
953
|
+
filterMutatePattern(fileNames, mutatePattern) {
|
|
954
|
+
const mutationRangeMatch = MUTATION_RANGE_REGEX.exec(mutatePattern);
|
|
955
|
+
let mutate = true;
|
|
956
|
+
if (mutationRangeMatch) {
|
|
957
|
+
const [_, newPattern, _mutationRange, startLine, startColumn = "0", endLine, endColumn = Number.MAX_SAFE_INTEGER.toString()] = mutationRangeMatch;
|
|
958
|
+
mutatePattern = newPattern ?? mutatePattern;
|
|
959
|
+
mutate = [{
|
|
960
|
+
start: {
|
|
961
|
+
line: parseInt(startLine ?? "1") - 1,
|
|
962
|
+
column: parseInt(startColumn)
|
|
963
|
+
},
|
|
964
|
+
end: {
|
|
965
|
+
line: parseInt(endLine ?? "1") - 1,
|
|
966
|
+
column: parseInt(endColumn)
|
|
967
|
+
}
|
|
968
|
+
}];
|
|
969
|
+
}
|
|
970
|
+
const matcher = new FileMatcher(mutatePattern, false);
|
|
971
|
+
const inputFiles = /* @__PURE__ */ new Map();
|
|
972
|
+
for (const fileName of fileNames) if (matcher.matches(fileName)) inputFiles.set(fileName, { mutate });
|
|
973
|
+
return inputFiles;
|
|
974
|
+
}
|
|
975
|
+
async resolveInputFileNames() {
|
|
976
|
+
const ignoreRules = this.ignoreRules.map((pattern) => new Minimatch(pattern, {
|
|
977
|
+
dot: true,
|
|
978
|
+
flipNegate: true,
|
|
979
|
+
nocase: true
|
|
980
|
+
}));
|
|
981
|
+
/**
|
|
982
|
+
* Rewrite of: https://github.com/npm/ignore-walk/blob/0e4f87adccb3e16f526d2e960ed04bdc77fd6cca/index.js#L213-L215
|
|
983
|
+
*/
|
|
984
|
+
const matchesDirectoryPartially = (entryPath, rule) => {
|
|
985
|
+
return rule.match(`/${entryPath}`, true) || rule.match(entryPath, true);
|
|
986
|
+
};
|
|
987
|
+
const matchesDirectory = (entryName, entryPath, rule) => {
|
|
988
|
+
return matchesFile(entryName, entryPath, rule) || rule.match(`/${entryPath}/`) || rule.match(`${entryPath}/`) || rule.negate && matchesDirectoryPartially(entryPath, rule);
|
|
989
|
+
};
|
|
990
|
+
const matchesFile = (entryName, entryPath, rule) => {
|
|
991
|
+
return rule.match(entryName) || rule.match(entryPath) || rule.match(`/${entryPath}`);
|
|
992
|
+
};
|
|
993
|
+
const crawlDir = async (dir, rootDir = dir) => {
|
|
994
|
+
const dirEntries = await this.fs.readdir(dir, { withFileTypes: true });
|
|
995
|
+
const relativeName = path.relative(rootDir, dir);
|
|
996
|
+
return (await Promise.all(dirEntries.filter((dirEntry) => {
|
|
997
|
+
let included = true;
|
|
998
|
+
const entryPath = `${relativeName.length ? `${relativeName}/` : ""}${dirEntry.name}`;
|
|
999
|
+
ignoreRules.forEach((rule) => {
|
|
1000
|
+
if (rule.negate !== included) {
|
|
1001
|
+
if (dirEntry.isDirectory() ? matchesDirectory(dirEntry.name, entryPath, rule) : matchesFile(dirEntry.name, entryPath, rule)) included = rule.negate;
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
return included;
|
|
1005
|
+
}).map(async (dirent) => {
|
|
1006
|
+
if (dirent.isDirectory()) return crawlDir(path.resolve(rootDir, relativeName, dirent.name), rootDir);
|
|
1007
|
+
else return path.resolve(rootDir, relativeName, dirent.name);
|
|
1008
|
+
}))).flat();
|
|
1009
|
+
};
|
|
1010
|
+
return await crawlDir(process.cwd());
|
|
1011
|
+
}
|
|
1012
|
+
async readIncrementalReport() {
|
|
1013
|
+
if (!this.incremental) return;
|
|
1014
|
+
try {
|
|
1015
|
+
const contents = await this.fs.readFile(this.incrementalFile, "utf-8");
|
|
1016
|
+
const result = JSON.parse(contents);
|
|
1017
|
+
const decoded = S.decodeUnknownSync(IncrementalReportSchema)(result);
|
|
1018
|
+
return {
|
|
1019
|
+
...decoded,
|
|
1020
|
+
files: Object.fromEntries(Object.entries(decoded.files).map(([fileName, file]) => [fileName, {
|
|
1021
|
+
...file,
|
|
1022
|
+
mutants: file.mutants.map((mutant) => ({
|
|
1023
|
+
...mutant,
|
|
1024
|
+
location: reportLocationToStrykerLocation(mutant.location)
|
|
1025
|
+
}))
|
|
1026
|
+
}])),
|
|
1027
|
+
...decoded.testFiles ? { testFiles: Object.fromEntries(Object.entries(decoded.testFiles).map(([fileName, file]) => [fileName, {
|
|
1028
|
+
...file,
|
|
1029
|
+
tests: file.tests.map((test) => ({
|
|
1030
|
+
...test,
|
|
1031
|
+
...test.location ? { location: reportOpenEndLocationToStrykerLocation(test.location) } : {}
|
|
1032
|
+
}))
|
|
1033
|
+
}])) } : {}
|
|
1034
|
+
};
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
if (isErrnoException(err) && err.code === ERROR_CODES.NoSuchFileOrDirectory) {
|
|
1037
|
+
this.log.info("No incremental result file found at %s, a full mutation testing run will be performed.", this.incrementalFile);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
throw err;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
function reportOpenEndLocationToStrykerLocation({ start, end }) {
|
|
1045
|
+
return {
|
|
1046
|
+
start: reportPositionToStrykerPosition(start),
|
|
1047
|
+
...end ? { end: reportPositionToStrykerPosition(end) } : {}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
function reportLocationToStrykerLocation({ start, end }) {
|
|
1051
|
+
return {
|
|
1052
|
+
start: reportPositionToStrykerPosition(start),
|
|
1053
|
+
end: reportPositionToStrykerPosition(end)
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function reportPositionToStrykerPosition({ line, column }) {
|
|
1057
|
+
return {
|
|
1058
|
+
line: line - 1,
|
|
1059
|
+
column: column - 1
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
//#endregion
|
|
1063
|
+
//#region src/worker-pool/kill.ts
|
|
1064
|
+
function kill(pid) {
|
|
1065
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1066
|
+
if (pid === void 0) {
|
|
1067
|
+
resolve();
|
|
1068
|
+
return promise;
|
|
1069
|
+
}
|
|
1070
|
+
treeKill(pid, "SIGKILL", (err) => {
|
|
1071
|
+
if (err && !canIgnore(err.code)) reject(err);
|
|
1072
|
+
else resolve();
|
|
1073
|
+
});
|
|
1074
|
+
function canIgnore(code) {
|
|
1075
|
+
return code === 255 || code === 128;
|
|
1076
|
+
}
|
|
1077
|
+
return promise;
|
|
1078
|
+
}
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/test-runner/command-test-runner.ts
|
|
1081
|
+
/**
|
|
1082
|
+
* A test runner that uses a (bash or cmd) command to execute the tests.
|
|
1083
|
+
* Does not know hom many tests are executed or any code coverage results,
|
|
1084
|
+
* instead, it mimics a simple test result based on the exit code.
|
|
1085
|
+
* The command can be configured, but defaults to `npm test`.
|
|
1086
|
+
*/
|
|
1087
|
+
var CommandTestRunner = class CommandTestRunner {
|
|
1088
|
+
workingDir;
|
|
1089
|
+
/**
|
|
1090
|
+
* "command"
|
|
1091
|
+
*/
|
|
1092
|
+
static runnerName = CommandTestRunner.name.replace("TestRunner", "").toLowerCase();
|
|
1093
|
+
/**
|
|
1094
|
+
* Determines whether a given name is "command" (ignore case)
|
|
1095
|
+
* @param name Maybe "command", maybe not
|
|
1096
|
+
*/
|
|
1097
|
+
static is(name) {
|
|
1098
|
+
return this.runnerName === name.toLowerCase();
|
|
1099
|
+
}
|
|
1100
|
+
settings;
|
|
1101
|
+
testFilesProvided;
|
|
1102
|
+
timeoutHandler;
|
|
1103
|
+
constructor(workingDir, options) {
|
|
1104
|
+
this.workingDir = workingDir;
|
|
1105
|
+
this.settings = options.commandRunner;
|
|
1106
|
+
this.testFilesProvided = testFilesProvided(options);
|
|
1107
|
+
}
|
|
1108
|
+
capabilities() {
|
|
1109
|
+
return { reloadEnvironment: true };
|
|
1110
|
+
}
|
|
1111
|
+
async init() {
|
|
1112
|
+
if (this.testFilesProvided) throw new Error(`The ${CommandTestRunner.runnerName} test runner does not support the --testFiles option.`);
|
|
1113
|
+
return Promise.resolve();
|
|
1114
|
+
}
|
|
1115
|
+
async dryRun(_options) {
|
|
1116
|
+
return this.run({});
|
|
1117
|
+
}
|
|
1118
|
+
async mutantRun({ activeMutant }) {
|
|
1119
|
+
const result = await this.run({ activeMutantId: activeMutant.id });
|
|
1120
|
+
return toMutantRunResult(result);
|
|
1121
|
+
}
|
|
1122
|
+
run({ activeMutantId }) {
|
|
1123
|
+
const timerInstance = new Timer();
|
|
1124
|
+
return new Promise((res, rej) => {
|
|
1125
|
+
const output = [];
|
|
1126
|
+
const env = activeMutantId === void 0 ? process.env : {
|
|
1127
|
+
...process.env,
|
|
1128
|
+
[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]: activeMutantId
|
|
1129
|
+
};
|
|
1130
|
+
const childProcess = exec(this.settings.command, {
|
|
1131
|
+
cwd: this.workingDir,
|
|
1132
|
+
env
|
|
1133
|
+
});
|
|
1134
|
+
if (childProcess.stdout === null || childProcess.stderr === null) throw new Error(`Expected stdout and stderr streams for "${this.settings.command}", but one of them is null.`);
|
|
1135
|
+
const stdout = childProcess.stdout;
|
|
1136
|
+
const stderr = childProcess.stderr;
|
|
1137
|
+
childProcess.on("error", (error) => {
|
|
1138
|
+
kill(childProcess.pid).then(() => handleResolve(errorResult(error))).catch(rej);
|
|
1139
|
+
});
|
|
1140
|
+
childProcess.on("exit", (code) => {
|
|
1141
|
+
const result = completeResult(code, timerInstance);
|
|
1142
|
+
handleResolve(result);
|
|
1143
|
+
});
|
|
1144
|
+
stdout.on("data", (chunk) => {
|
|
1145
|
+
output.push(chunk);
|
|
1146
|
+
});
|
|
1147
|
+
stderr.on("data", (chunk) => {
|
|
1148
|
+
output.push(chunk);
|
|
1149
|
+
});
|
|
1150
|
+
this.timeoutHandler = async () => {
|
|
1151
|
+
handleResolve({ status: DryRunStatus.Timeout });
|
|
1152
|
+
await kill(childProcess.pid);
|
|
1153
|
+
};
|
|
1154
|
+
const handleResolve = (runResult) => {
|
|
1155
|
+
removeAllListeners();
|
|
1156
|
+
this.timeoutHandler = void 0;
|
|
1157
|
+
res(runResult);
|
|
1158
|
+
};
|
|
1159
|
+
function removeAllListeners() {
|
|
1160
|
+
stderr.removeAllListeners();
|
|
1161
|
+
stdout.removeAllListeners();
|
|
1162
|
+
childProcess.removeAllListeners();
|
|
1163
|
+
}
|
|
1164
|
+
function errorResult(error) {
|
|
1165
|
+
return {
|
|
1166
|
+
errorMessage: errorToString(error),
|
|
1167
|
+
status: DryRunStatus.Error
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
function completeResult(exitCode, timer) {
|
|
1171
|
+
const duration = timer.elapsedMs();
|
|
1172
|
+
if (exitCode === 0) return {
|
|
1173
|
+
status: DryRunStatus.Complete,
|
|
1174
|
+
tests: [{
|
|
1175
|
+
id: "all",
|
|
1176
|
+
name: "All tests",
|
|
1177
|
+
status: TestStatus.Success,
|
|
1178
|
+
timeSpentMs: duration
|
|
1179
|
+
}]
|
|
1180
|
+
};
|
|
1181
|
+
else return {
|
|
1182
|
+
status: DryRunStatus.Complete,
|
|
1183
|
+
tests: [{
|
|
1184
|
+
id: "all",
|
|
1185
|
+
failureMessage: output.map((buf) => buf.toString()).join(os.EOL),
|
|
1186
|
+
name: "All tests",
|
|
1187
|
+
status: TestStatus.Failed,
|
|
1188
|
+
timeSpentMs: duration
|
|
1189
|
+
}]
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
async dispose() {
|
|
1195
|
+
if (this.timeoutHandler) await this.timeoutHandler();
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
//#endregion
|
|
1199
|
+
//#region src/config/is-warning-enabled.ts
|
|
1200
|
+
function isWarningEnabled(warningType, warningOptions) {
|
|
1201
|
+
if (typeof warningOptions === "boolean") return warningOptions;
|
|
1202
|
+
else return !!warningOptions[warningType];
|
|
1203
|
+
}
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/config/options-path.ts
|
|
1206
|
+
/**
|
|
1207
|
+
* Print the name of (or path to) a stryker option
|
|
1208
|
+
*/
|
|
1209
|
+
const optionsPath = propertyPath();
|
|
1210
|
+
//#endregion
|
|
1211
|
+
//#region src/config/validation-errors.ts
|
|
1212
|
+
/** `["mutator"]["name"]` — the path shape a rendered schema issue carries. */
|
|
1213
|
+
const PATH_LINE = /^at\s+(\[.*\])$/;
|
|
1214
|
+
const PATH_SEGMENT = /\["([^"]*)"\]|\[(\d+)\]/g;
|
|
1215
|
+
const EXPECTED_PATTERN = /^Expected a string matching the RegExp (.+)$/;
|
|
1216
|
+
const EXPECTED_TYPE = /^Expected (.+)$/;
|
|
1217
|
+
/** `["mutator"]["name"]` -> `mutator.name`; an index stays bracketed. */
|
|
1218
|
+
const dottedPath = (raw) => {
|
|
1219
|
+
let path = "";
|
|
1220
|
+
for (const [, key, index] of raw.matchAll(PATH_SEGMENT)) if (index !== void 0) path += `[${index}]`;
|
|
1221
|
+
else path += path.length > 0 ? `.${key ?? ""}` : key ?? "";
|
|
1222
|
+
return path;
|
|
1223
|
+
};
|
|
1224
|
+
/** The expectation, in the phrasing an operator reading the terminal already knows. */
|
|
1225
|
+
const phrase = (expectation) => {
|
|
1226
|
+
const pattern = EXPECTED_PATTERN.exec(expectation);
|
|
1227
|
+
if (pattern !== null) return `must match pattern "${pattern[1] ?? ""}"`;
|
|
1228
|
+
const type = EXPECTED_TYPE.exec(expectation);
|
|
1229
|
+
if (type !== null) return `should be ${type[1] ?? ""}`;
|
|
1230
|
+
return expectation;
|
|
1231
|
+
};
|
|
1232
|
+
/**
|
|
1233
|
+
* Renders a decode failure as one message per offending option.
|
|
1234
|
+
*
|
|
1235
|
+
* This replaced roughly two hundred lines that existed to make a JSON Schema
|
|
1236
|
+
* engine's error objects readable: they deduplicated errors shadowing each other
|
|
1237
|
+
* on one path, merged the several type errors a union produced for one option,
|
|
1238
|
+
* and rebuilt the option's path by hand. The schema issue already carries the
|
|
1239
|
+
* expectation and the path, so what remains is naming the option and keeping the
|
|
1240
|
+
* phrasing the CLI contract pins — an operator is told which option is wrong and
|
|
1241
|
+
* what it must be, not how the value was decoded.
|
|
1242
|
+
*
|
|
1243
|
+
* An issue is an expectation line followed by its indented `at [...]` path. If
|
|
1244
|
+
* that shape ever changes, each line arrives as its own message: the phrasing
|
|
1245
|
+
* degrades and no information is lost.
|
|
1246
|
+
*/
|
|
1247
|
+
function describeErrors(error) {
|
|
1248
|
+
const messages = [];
|
|
1249
|
+
let expectation;
|
|
1250
|
+
for (const raw of error.message.split("\n")) {
|
|
1251
|
+
const line = raw.trim();
|
|
1252
|
+
if (line.length === 0) continue;
|
|
1253
|
+
const path = PATH_LINE.exec(line);
|
|
1254
|
+
if (path !== null && expectation !== void 0) {
|
|
1255
|
+
messages.push(`Config option "${dottedPath(path[1] ?? "")}" ${phrase(expectation)}.`);
|
|
1256
|
+
expectation = void 0;
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (expectation !== void 0) messages.push(expectation);
|
|
1260
|
+
expectation = line;
|
|
1261
|
+
}
|
|
1262
|
+
if (expectation !== void 0) messages.push(expectation);
|
|
1263
|
+
return messages.length > 0 ? messages : [error.message];
|
|
1264
|
+
}
|
|
1265
|
+
//#endregion
|
|
1266
|
+
//#region src/config/options-validator.ts
|
|
1267
|
+
/**
|
|
1268
|
+
* Options are decoded by the schema that declares them. This replaced an ajv
|
|
1269
|
+
* instance run with `useDefaults`, which filled defaults from the *derived* JSON
|
|
1270
|
+
* Schema document and silently injected nothing wherever a `default` annotation
|
|
1271
|
+
* did not survive that derivation: `timeoutFactor`, `timeoutMS` and
|
|
1272
|
+
* `dryRunTimeoutMinutes` all arrived `undefined`, so
|
|
1273
|
+
* `dryRunTimeoutMinutes * 1000 * 60` was `NaN`, every dry run was given a 1 ms
|
|
1274
|
+
* budget, and each one reported "Initial test run timed out". Decoding through
|
|
1275
|
+
* the declaration cannot drift from it, and the open struct keeps the
|
|
1276
|
+
* plugin-contributed options a document-driven validator would have rejected.
|
|
1277
|
+
*
|
|
1278
|
+
* `errors: 'all'` keeps the previous behaviour of reporting every offending
|
|
1279
|
+
* option in one pass rather than stopping at the first.
|
|
1280
|
+
*/
|
|
1281
|
+
const decodeOptions = S.decodeUnknownResult(StrykerOptionsSchema, { errors: "all" });
|
|
1282
|
+
var OptionsValidator = class OptionsValidator {
|
|
1283
|
+
schema;
|
|
1284
|
+
log;
|
|
1285
|
+
static inject = tokens(validationSchema, commonTokens.logger);
|
|
1286
|
+
constructor(schema, log) {
|
|
1287
|
+
this.schema = schema;
|
|
1288
|
+
this.log = log;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Validates the provided options, throwing an error if something is wrong.
|
|
1292
|
+
* Optionally also warns about excess or unserializable options.
|
|
1293
|
+
* @param options The stryker options to validate
|
|
1294
|
+
* @param mark Wether or not to log warnings on unknown properties or unserializable properties
|
|
1295
|
+
*/
|
|
1296
|
+
validate(options, mark = false) {
|
|
1297
|
+
this.removeDeprecatedOptions(options);
|
|
1298
|
+
this.validateRemovedSurface(options);
|
|
1299
|
+
this.schemaValidate(options);
|
|
1300
|
+
this.customValidation(options);
|
|
1301
|
+
if (mark) this.markOptions(options);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Hard-fails on config names the rebuild removed (U2-U9): a removed
|
|
1305
|
+
* top-level key (`dashboard`, `eventReporter`) or a removed reporter name
|
|
1306
|
+
* inside `reporters` (`dots`, `event-recorder`, `progress-append-only`,
|
|
1307
|
+
* `dashboard`). Runs ahead of `schemaValidate` because a removed key is
|
|
1308
|
+
* absent from the AJV schema, so schema validation can never see it, and
|
|
1309
|
+
* the warning path (`markExcessOptions`) only fires when `mark` is set and
|
|
1310
|
+
* the `unknownOptions` warning is enabled — a removed key could otherwise
|
|
1311
|
+
* be silently accepted. Only the known-removed names are checked; an
|
|
1312
|
+
* unknown-but-not-removed key keeps its warning-only behaviour.
|
|
1313
|
+
*/
|
|
1314
|
+
validateRemovedSurface(rawOptions) {
|
|
1315
|
+
const errors = [];
|
|
1316
|
+
for (const key of Object.keys(rawOptions)) if (Object.hasOwn(REMOVED_OPTIONS, key)) errors.push(`Config option "${key}" is no longer supported. ${REMOVED_OPTIONS[key]}`);
|
|
1317
|
+
const reporters = rawOptions["reporters"];
|
|
1318
|
+
if (Array.isArray(reporters)) {
|
|
1319
|
+
for (const name of reporters) if (typeof name === "string" && Object.hasOwn(REMOVED_OPTIONS, name)) errors.push(`Config option "reporters" contains removed reporter name "${name}". ${REMOVED_OPTIONS[name]}`);
|
|
1320
|
+
}
|
|
1321
|
+
errors.forEach((error) => this.log.error(error));
|
|
1322
|
+
this.throwErrorIfNeeded(errors);
|
|
1323
|
+
}
|
|
1324
|
+
removeDeprecatedOptions(rawOptions) {
|
|
1325
|
+
this.removeStringMutator(rawOptions);
|
|
1326
|
+
this.removeMutatorName(rawOptions);
|
|
1327
|
+
this.removeTestFramework(rawOptions);
|
|
1328
|
+
this.removeTranspilers(rawOptions);
|
|
1329
|
+
this.rewriteFiles(rawOptions);
|
|
1330
|
+
this.removeJestEnableBail(rawOptions);
|
|
1331
|
+
this.removeHtmlReporterBaseDir(rawOptions);
|
|
1332
|
+
this.migrateMaxConcurrentTestRunners(rawOptions);
|
|
1333
|
+
}
|
|
1334
|
+
static recordOf(value) {
|
|
1335
|
+
return S.decodeUnknownSync(S.Record(S.String, S.Unknown))(value);
|
|
1336
|
+
}
|
|
1337
|
+
removeStringMutator(rawOptions) {
|
|
1338
|
+
if (typeof rawOptions["mutator"] !== "string") return;
|
|
1339
|
+
this.log.warn("DEPRECATED. Use of \"mutator\" as string is no longer needed. You can remove it from your configuration. Stryker now supports mutating of JavaScript and friend files out of the box.");
|
|
1340
|
+
delete rawOptions["mutator"];
|
|
1341
|
+
}
|
|
1342
|
+
removeMutatorName(rawOptions) {
|
|
1343
|
+
const mutator = rawOptions["mutator"];
|
|
1344
|
+
if (typeof mutator !== "object" || mutator === null) return;
|
|
1345
|
+
const mutatorRecord = OptionsValidator.recordOf(mutator);
|
|
1346
|
+
if (!mutatorRecord["name"]) return;
|
|
1347
|
+
this.log.warn("DEPRECATED. Use of \"mutator.name\" is no longer needed. You can remove \"mutator.name\" from your configuration. Stryker now supports mutating of JavaScript and friend files out of the box.");
|
|
1348
|
+
delete mutatorRecord["name"];
|
|
1349
|
+
rawOptions["mutator"] = mutatorRecord;
|
|
1350
|
+
}
|
|
1351
|
+
removeTestFramework(rawOptions) {
|
|
1352
|
+
if (!Object.keys(rawOptions).includes("testFramework")) return;
|
|
1353
|
+
this.log.warn("DEPRECATED. Use of \"testFramework\" is no longer needed. You can remove it from your configuration. Your test runner plugin now handles its own test framework integration.");
|
|
1354
|
+
delete rawOptions["testFramework"];
|
|
1355
|
+
}
|
|
1356
|
+
removeTranspilers(rawOptions) {
|
|
1357
|
+
const transpilers = rawOptions["transpilers"];
|
|
1358
|
+
if (!Array.isArray(transpilers)) return;
|
|
1359
|
+
const example = transpilers.includes("babel") ? "babel src --out-dir lib" : transpilers.includes("typescript") ? "tsc -b" : transpilers.includes("webpack") ? "webpack --config webpack.config.js" : "npm run build";
|
|
1360
|
+
this.log.warn(`DEPRECATED. Support for "transpilers" is removed. You can now configure your own "${optionsPath("buildCommand")}". For example, ${example}.`);
|
|
1361
|
+
delete rawOptions["transpilers"];
|
|
1362
|
+
}
|
|
1363
|
+
rewriteFiles(rawOptions) {
|
|
1364
|
+
const files = rawOptions["files"];
|
|
1365
|
+
if (!Array.isArray(files)) return;
|
|
1366
|
+
const ignorePatternsName = optionsPath("ignorePatterns");
|
|
1367
|
+
const isString = (uncertain) => typeof uncertain === "string";
|
|
1368
|
+
const newIgnorePatterns = ["**", ...files.filter(isString).map((filePattern) => filePattern.startsWith("!") ? filePattern.substr(1) : `!${filePattern}`)];
|
|
1369
|
+
delete rawOptions["files"];
|
|
1370
|
+
this.log.warn(`DEPRECATED. Use of "files" is deprecated, please use "${ignorePatternsName}" instead (or remove "files" altogether will probably work as well). For now, rewriting them as ${JSON.stringify(newIgnorePatterns)}. See https://stryker-mutator.io/docs/stryker-js/configuration/#ignorepatterns-string`);
|
|
1371
|
+
const existingIgnorePatterns = Array.isArray(rawOptions[ignorePatternsName]) ? rawOptions[ignorePatternsName] : [];
|
|
1372
|
+
rawOptions[ignorePatternsName] = [...newIgnorePatterns, ...existingIgnorePatterns];
|
|
1373
|
+
}
|
|
1374
|
+
removeJestEnableBail(rawOptions) {
|
|
1375
|
+
const jestOptions = rawOptions["jest"];
|
|
1376
|
+
if (typeof jestOptions !== "object" || jestOptions === null) return;
|
|
1377
|
+
const jestRecord = OptionsValidator.recordOf(jestOptions);
|
|
1378
|
+
const enableBail = jestRecord["enableBail"];
|
|
1379
|
+
if (enableBail === void 0) return;
|
|
1380
|
+
this.log.warn("DEPRECATED. Use of \"jest.enableBail\" is deprecated, please use \"disableBail\" instead. See https://stryker-mutator.io/docs/stryker-js/configuration#disablebail-boolean");
|
|
1381
|
+
rawOptions["disableBail"] = !enableBail;
|
|
1382
|
+
delete jestRecord["enableBail"];
|
|
1383
|
+
rawOptions["jest"] = jestRecord;
|
|
1384
|
+
}
|
|
1385
|
+
removeHtmlReporterBaseDir(rawOptions) {
|
|
1386
|
+
const htmlReporter = rawOptions["htmlReporter"];
|
|
1387
|
+
if (typeof htmlReporter !== "object" || htmlReporter === null) return;
|
|
1388
|
+
const reporter = OptionsValidator.recordOf(htmlReporter);
|
|
1389
|
+
const baseDir = reporter["baseDir"];
|
|
1390
|
+
if (!baseDir) return;
|
|
1391
|
+
this.log.warn(`DEPRECATED. Use of "htmlReporter.baseDir" is deprecated, please use "${optionsPath("htmlReporter", "fileName")}" instead. See https://stryker-mutator.io/docs/stryker-js/configuration/#reporters-string`);
|
|
1392
|
+
const baseDirText = typeof baseDir === "string" ? baseDir : JSON.stringify(baseDir) ?? "";
|
|
1393
|
+
if (!reporter["fileName"]) reporter["fileName"] = path.join(baseDirText, "index.html");
|
|
1394
|
+
delete reporter["baseDir"];
|
|
1395
|
+
rawOptions["htmlReporter"] = reporter;
|
|
1396
|
+
}
|
|
1397
|
+
migrateMaxConcurrentTestRunners(rawOptions) {
|
|
1398
|
+
const maxConcurrent = rawOptions["maxConcurrentTestRunners"];
|
|
1399
|
+
if (typeof maxConcurrent !== "number" || maxConcurrent === Number.MAX_SAFE_INTEGER) return;
|
|
1400
|
+
this.log.warn("DEPRECATED. Use of \"maxConcurrentTestRunners\" is deprecated. Please use \"concurrency\" instead.");
|
|
1401
|
+
if (!rawOptions["concurrency"] && maxConcurrent < os.availableParallelism() - 1) rawOptions["concurrency"] = maxConcurrent;
|
|
1402
|
+
}
|
|
1403
|
+
customValidation(options) {
|
|
1404
|
+
const additionalErrors = [];
|
|
1405
|
+
if (options.thresholds.high < options.thresholds.low) additionalErrors.push("Config option \"thresholds.high\" should be higher than \"thresholds.low\".");
|
|
1406
|
+
if (CommandTestRunner.is(options.testRunner)) {
|
|
1407
|
+
if (options.testRunnerNodeArgs.length) this.log.warn("Using \"testRunnerNodeArgs\" together with the \"command\" test runner is not supported, these arguments will be ignored. You can add your custom arguments by setting the \"commandRunner.command\" option.");
|
|
1408
|
+
}
|
|
1409
|
+
if (options.ignoreStatic && options.coverageAnalysis !== "perTest") additionalErrors.push(`Config option "${optionsPath("ignoreStatic")}" is not supported with coverage analysis "${options.coverageAnalysis}". Either turn off "${optionsPath("ignoreStatic")}", or configure "${optionsPath("coverageAnalysis")}" to be "perTest".`);
|
|
1410
|
+
options.mutate.forEach((mutateString, index) => {
|
|
1411
|
+
const match = MUTATION_RANGE_REGEX.exec(mutateString);
|
|
1412
|
+
if (match) {
|
|
1413
|
+
if (new Minimatch(mutateString).hasMagic()) additionalErrors.push(`Config option "mutate[${index}]" is invalid. Cannot combine a glob expression with a mutation range in "${mutateString}".`);
|
|
1414
|
+
else {
|
|
1415
|
+
const [_, _fileName, mutationRange, startLine, _startColumn, endLine, _endColumn] = match;
|
|
1416
|
+
const start = parseInt(startLine ?? "", 10);
|
|
1417
|
+
const end = parseInt(endLine ?? "", 10);
|
|
1418
|
+
if (start < 1) additionalErrors.push(`Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid, line ${start} does not exist (lines start at 1).`);
|
|
1419
|
+
if (start > end) additionalErrors.push(`Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid. The "from" line number (${start}) should be less then the "to" line number (${end}).`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
additionalErrors.forEach((error) => this.log.error(error));
|
|
1424
|
+
this.throwErrorIfNeeded(additionalErrors);
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Decodes the options against their declaration, then writes the result back
|
|
1428
|
+
* onto the caller's object: the previous engine filled defaults in place and
|
|
1429
|
+
* every caller reads them off the object it passed, so the fill has to land
|
|
1430
|
+
* there rather than in a copy.
|
|
1431
|
+
*/
|
|
1432
|
+
schemaValidate(options) {
|
|
1433
|
+
const decoded = decodeOptions(options);
|
|
1434
|
+
if (Result$1.isFailure(decoded)) {
|
|
1435
|
+
const describedErrors = describeErrors(decoded.failure);
|
|
1436
|
+
describedErrors.forEach((error) => this.log.error(error));
|
|
1437
|
+
this.throwErrorIfNeeded(describedErrors);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
Object.assign(options, decoded.success);
|
|
1441
|
+
}
|
|
1442
|
+
throwErrorIfNeeded(errors) {
|
|
1443
|
+
if (errors.length > 0) {
|
|
1444
|
+
const headline = errors.length === 1 ? "Please correct this configuration error and try again." : "Please correct these configuration errors and try again.";
|
|
1445
|
+
throw new ConfigError(`${headline} ${errors.join(" ")}`);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
markOptions(options) {
|
|
1449
|
+
this.markExcessOptions(options);
|
|
1450
|
+
this.markUnserializableOptions(options);
|
|
1451
|
+
}
|
|
1452
|
+
markExcessOptions(options) {
|
|
1453
|
+
const OPTIONS_ADDED_BY_STRYKER = [
|
|
1454
|
+
"set",
|
|
1455
|
+
"configFile",
|
|
1456
|
+
"$schema"
|
|
1457
|
+
];
|
|
1458
|
+
if (isWarningEnabled("unknownOptions", options.warnings)) {
|
|
1459
|
+
const schemaProperties = OptionsValidator.recordOf(this.schema["properties"] ?? {});
|
|
1460
|
+
const schemaKeys = Object.keys(schemaProperties);
|
|
1461
|
+
const excessPropertyNames = Object.keys(options).filter((key) => !key.endsWith("_comment")).filter((key) => !OPTIONS_ADDED_BY_STRYKER.includes(key)).filter((key) => !schemaKeys.includes(key));
|
|
1462
|
+
if (excessPropertyNames.length) {
|
|
1463
|
+
excessPropertyNames.forEach((excessPropertyName) => {
|
|
1464
|
+
this.log.warn(`Unknown stryker config option "${excessPropertyName}".`);
|
|
1465
|
+
});
|
|
1466
|
+
this.log.warn(`Possible causes:
|
|
1467
|
+
* Is it a typo on your end?
|
|
1468
|
+
* Did you only write this property as a comment? If so, please postfix it with "_comment".
|
|
1469
|
+
* You might be missing a plugin that is supposed to use it. Stryker loaded plugins from: ${JSON.stringify(options.plugins)}
|
|
1470
|
+
* The plugin that is using it did not contribute explicit validation.
|
|
1471
|
+
(disable "${optionsPath("warnings", "unknownOptions")}" to ignore this warning)`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
markUnserializableOptions(options) {
|
|
1476
|
+
if (isWarningEnabled("unserializableOptions", options.warnings)) {
|
|
1477
|
+
const unserializables = findUnserializables(options);
|
|
1478
|
+
if (unserializables) {
|
|
1479
|
+
unserializables.forEach((unserializable) => this.log.warn(`Config option "${unserializable.path.join(".")}" is not (fully) serializable. ${unserializable.reason}. Any test runner or checker worker processes might not receive this value as intended.`));
|
|
1480
|
+
this.log.warn(`(disable ${optionsPath("warnings", "unserializableOptions")} to ignore this warning)`);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
/**
|
|
1486
|
+
* The defaults are the declaration decoded against an empty object: every field
|
|
1487
|
+
* that declares a default supplies it, and the result is the complete option
|
|
1488
|
+
* set. It no longer routes through the validator, which used to be the only way
|
|
1489
|
+
* to obtain them and produced an object missing three numeric defaults.
|
|
1490
|
+
*/
|
|
1491
|
+
function createDefaultOptions() {
|
|
1492
|
+
return Result$1.getOrThrow(decodeOptions({}));
|
|
1493
|
+
}
|
|
1494
|
+
const defaultOptions = deepFreeze(createDefaultOptions());
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/plugins/plugin-loader.schema.ts
|
|
1497
|
+
/**
|
|
1498
|
+
* The shape of a module namespace that contributes Stryker plugins:
|
|
1499
|
+
* an exported `strykerPlugins` array. Elements are runtime plugin objects,
|
|
1500
|
+
* so only array-ness is checked here.
|
|
1501
|
+
*/
|
|
1502
|
+
const PluginModuleSchema = Schema.Struct({ strykerPlugins: Schema.Array(Schema.Unknown) });
|
|
1503
|
+
/**
|
|
1504
|
+
* The shape of a module namespace that contributes a JSON schema
|
|
1505
|
+
* extension: an exported `strykerValidationSchema` record.
|
|
1506
|
+
*/
|
|
1507
|
+
const SchemaValidationContributionSchema = Schema.Struct({ strykerValidationSchema: Schema.Record(Schema.String, Schema.Unknown) });
|
|
1508
|
+
//#endregion
|
|
1509
|
+
//#region src/plugins/plugin-loader.ts
|
|
1510
|
+
const IGNORED_PACKAGES = [
|
|
1511
|
+
"core",
|
|
1512
|
+
"api",
|
|
1513
|
+
"util",
|
|
1514
|
+
"instrumenter"
|
|
1515
|
+
];
|
|
1516
|
+
/**
|
|
1517
|
+
* Can resolve modules and pull them into memory
|
|
1518
|
+
*/
|
|
1519
|
+
var PluginLoader = class {
|
|
1520
|
+
log;
|
|
1521
|
+
static inject = tokens(commonTokens.logger);
|
|
1522
|
+
constructor(log) {
|
|
1523
|
+
this.log = log;
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Loads plugins based on configured plugin descriptors.
|
|
1527
|
+
* A plugin descriptor can be:
|
|
1528
|
+
* * A full url: "file:///home/nicojs/github/my-plugin.js"
|
|
1529
|
+
* * An absolute file path: "/home/nicojs/github/my-plugin.js"
|
|
1530
|
+
* * A relative path: "./my-plugin.js"
|
|
1531
|
+
* * A bare import expression: "@stryker-mutator/karma-runner"
|
|
1532
|
+
* * A simple glob expression (only wild cards are supported): "@stryker-mutator/*"
|
|
1533
|
+
*/
|
|
1534
|
+
async load(pluginDescriptors) {
|
|
1535
|
+
const pluginModules = await this.resolvePluginModules(pluginDescriptors);
|
|
1536
|
+
const loadedPluginModules = (await Promise.all(pluginModules.map(async (moduleName) => {
|
|
1537
|
+
return {
|
|
1538
|
+
...await this.loadPlugin(moduleName),
|
|
1539
|
+
moduleName
|
|
1540
|
+
};
|
|
1541
|
+
}))).filter(notEmpty);
|
|
1542
|
+
const result = {
|
|
1543
|
+
schemaContributions: [],
|
|
1544
|
+
pluginsByKind: /* @__PURE__ */ new Map(),
|
|
1545
|
+
pluginModulePaths: []
|
|
1546
|
+
};
|
|
1547
|
+
loadedPluginModules.forEach(({ plugins, schemaContribution, moduleName }) => {
|
|
1548
|
+
if (plugins) {
|
|
1549
|
+
result.pluginModulePaths.push(moduleName);
|
|
1550
|
+
plugins.forEach((plugin) => {
|
|
1551
|
+
const pluginsForKind = result.pluginsByKind.get(plugin.kind);
|
|
1552
|
+
if (pluginsForKind) pluginsForKind.push(plugin);
|
|
1553
|
+
else result.pluginsByKind.set(plugin.kind, [plugin]);
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
if (schemaContribution) result.schemaContributions.push(schemaContribution);
|
|
1557
|
+
});
|
|
1558
|
+
return result;
|
|
1559
|
+
}
|
|
1560
|
+
async resolvePluginModules(pluginDescriptors) {
|
|
1561
|
+
return (await Promise.all(pluginDescriptors.map(async (pluginExpression) => {
|
|
1562
|
+
if (pluginExpression.includes("*")) return await this.globPluginModules(pluginExpression);
|
|
1563
|
+
else if (path.isAbsolute(pluginExpression) || pluginExpression.startsWith(".")) return pathToFileURL(path.resolve(pluginExpression)).toString();
|
|
1564
|
+
else return pluginExpression;
|
|
1565
|
+
}))).filter(notEmpty).flat();
|
|
1566
|
+
}
|
|
1567
|
+
async globPluginModules(pluginExpression) {
|
|
1568
|
+
const { org, pkg } = parsePluginExpression(pluginExpression);
|
|
1569
|
+
const pluginDirectory = path.resolve(fileURLToPath(new URL("../../../../../", import.meta.url)), org);
|
|
1570
|
+
const regexp = new RegExp("^" + pkg.replace("*", ".*"));
|
|
1571
|
+
this.log.debug("Loading %s from %s", pluginExpression, pluginDirectory);
|
|
1572
|
+
const plugins = (await fs.promises.readdir(pluginDirectory)).filter((pluginName) => !IGNORED_PACKAGES.includes(pluginName) && regexp.test(pluginName)).map((pluginName) => `${org.length ? `${org}/` : ""}${pluginName}`);
|
|
1573
|
+
if (plugins.length === 0 && !defaultOptions.plugins.includes(pluginExpression)) this.log.warn("Expression \"%s\" not resulted in plugins to load.", pluginExpression);
|
|
1574
|
+
plugins.forEach((plugin) => this.log.debug("Loading plugin \"%s\" (matched with expression %s)", plugin, pluginExpression));
|
|
1575
|
+
return plugins;
|
|
1576
|
+
}
|
|
1577
|
+
async loadPlugin(descriptor) {
|
|
1578
|
+
this.log.debug("Loading plugin %s", descriptor);
|
|
1579
|
+
try {
|
|
1580
|
+
const module = await importModule(descriptor);
|
|
1581
|
+
const plugins = isPluginModule(module) ? module.strykerPlugins : void 0;
|
|
1582
|
+
const schemaContribution = hasValidationSchemaContribution(module) ? module.strykerValidationSchema : void 0;
|
|
1583
|
+
if (plugins ?? schemaContribution) return {
|
|
1584
|
+
plugins,
|
|
1585
|
+
schemaContribution
|
|
1586
|
+
};
|
|
1587
|
+
else this.log.warn("Module \"%s\" did not contribute a StrykerJS plugin. It didn't export a \"%s\" or \"%s\".", descriptor, propertyPath()("strykerPlugins"), propertyPath()("strykerValidationSchema"));
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
if (isErrnoException(e) && e.code === "ERR_MODULE_NOT_FOUND" && e.message.indexOf(descriptor) !== -1) this.log.warn("Cannot find plugin \"%s\".\n Did you forget to install it ?", descriptor);
|
|
1590
|
+
else this.log.warn("Error during loading \"%s\" plugin:\n %s", descriptor, errorToString(e));
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
/**
|
|
1595
|
+
* Distills organization name from a package expression.
|
|
1596
|
+
* @example
|
|
1597
|
+
* '@stryker-mutator/core' => { org: '@stryker-mutator', 'core' }
|
|
1598
|
+
* 'glob' => { org: '', 'glob' }
|
|
1599
|
+
*/
|
|
1600
|
+
function parsePluginExpression(pluginExpression) {
|
|
1601
|
+
const parts = pluginExpression.split("/");
|
|
1602
|
+
if (parts.length > 1) return {
|
|
1603
|
+
org: parts.slice(0, parts.length - 1).join("/"),
|
|
1604
|
+
pkg: parts[parts.length - 1] ?? ""
|
|
1605
|
+
};
|
|
1606
|
+
else return {
|
|
1607
|
+
org: "",
|
|
1608
|
+
pkg: parts[0] ?? ""
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
function isPluginModule(module) {
|
|
1612
|
+
return Schema.is(PluginModuleSchema)(module);
|
|
1613
|
+
}
|
|
1614
|
+
function hasValidationSchemaContribution(module) {
|
|
1615
|
+
return Schema.is(SchemaValidationContributionSchema)(module);
|
|
1616
|
+
}
|
|
1617
|
+
//#endregion
|
|
1618
|
+
export { testRunnerPool as $, mutantTestPlanner as A, reporter as B, injection_tokens_exports as C, loggingServer as D, loggerShowColors as E, pluginModulePaths as F, runEventSink as G, reporterPluginModules as H, pluginsByKind as I, sandbox as J, runId as K, process$1 as L, mutationTestReportHelper as M, optionsValidator as N, loggingServerAddress as O, pluginCreator as P, testRunnerFactory as Q, progressEnabled as R, incrementalDiffer as S, loggerConsoleOut as T, requireFromCwd as U, reporterOverride as V, resolvedMode as W, testCoverage as X, temporaryDirectory as Y, testRunnerConcurrencyTokens as Z, clearTextEnabled as _, isWarningEnabled as a, dryRunResult as b, ProjectReader as c, ConfigReader as d, timeOverheadMS as et, FileSystem as f, checkerPool as g, checkerFactory as h, optionsPath as i, workerIdGenerator as it, mutants as j, loggingSink as k, MetaSchemaBuilder as l, checkerConcurrencyTokens as m, OptionsValidator as n, unexpectedExitRegistry as nt, CommandTestRunner as o, PluginCreator as p, runStartedAt as q, defaultOptions as r, validationSchema as rt, kill as s, PluginLoader as t, timer as tt, FileMatcher as u, concurrencyTokenProvider as v, loggerActiveLevel as w, execa as x, disableTypeChecksHelper as y, project as z };
|