@systemfsoftware/stryker-js 3.0.2 → 5.0.0
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/CHANGELOG.md +101 -0
- package/LICENSE +3 -15
- package/README.md +69 -21
- package/dist/Plugins-sA_9VKus.d.mts +96 -0
- package/dist/builtin-reporters-wAuKFSsd.mjs +672 -0
- package/dist/builtin-reporters.d.mts +14 -0
- package/dist/builtin-reporters.mjs +2 -0
- package/dist/compiler-Bq9Nelu8.mjs +48406 -0
- package/dist/config.d.mts +11 -0
- package/dist/config.mjs +7 -0
- package/dist/dist-B3nT7z4G.mjs +5155 -0
- package/dist/execAsync-CfPZgJSD.mjs +10 -0
- package/dist/execAsync-DdCu5QLN.mjs +10 -0
- package/dist/getMachineId-bsd-C6bLIaiv.mjs +27 -0
- package/dist/getMachineId-bsd-CXSiHDga.mjs +27 -0
- package/dist/getMachineId-darwin-D3cIKeqx.mjs +24 -0
- package/dist/getMachineId-darwin-DPZ0kdKD.mjs +24 -0
- package/dist/getMachineId-linux-CQwLSlZk.mjs +20 -0
- package/dist/getMachineId-linux-Ci8OVJU0.mjs +20 -0
- package/dist/getMachineId-unsupported-PX0hnW6X.mjs +15 -0
- package/dist/getMachineId-unsupported-_ExL3Avl.mjs +15 -0
- package/dist/getMachineId-win-BCsS9pC_.mjs +26 -0
- package/dist/getMachineId-win-D9Tb0p7w.mjs +26 -0
- package/dist/index.d.mts +1009 -8
- package/dist/index.mjs +3 -9
- package/dist/main.mjs +104333 -0
- package/dist/merge-config-BrrNQWje.mjs +16 -0
- package/dist/mutation-test.cell-RmF-sBUm.d.mts +509 -0
- package/dist/output-mode-CfGHDpi5.d.mts +17 -0
- package/dist/parser.wasm32-wasi.wasm +0 -0
- package/dist/plugin-loader.d.mts +106 -0
- package/dist/plugin-loader.mjs +2 -0
- package/dist/plugin-worker-entry-DNu0SHO8.mjs +1413 -0
- package/dist/promises.d.mts +5 -0
- package/dist/promises.mjs +14 -0
- package/dist/rolldown-runtime-Dr7-SnC6.mjs +45 -0
- package/dist/src-Bzh7ykDp.mjs +4724 -0
- package/dist/stryker-config-BlfHxS7n.d.mts +14 -0
- package/dist/wasm-Dr7kZatW.mjs +13017 -0
- package/package.json +81 -38
- package/dist/Checker-A8o5B3Ub.d.mts +0 -40
- package/dist/Checker-Bq8NXD8d.mjs +0 -25
- package/dist/Checker.d.mts +0 -2
- package/dist/Checker.mjs +0 -2
- package/dist/Evaluator-CnAN8-pZ.d.mts +0 -19
- package/dist/Evaluator.d.mts +0 -3
- package/dist/Evaluator.mjs +0 -9
- package/dist/ExitClass.d.mts +0 -9
- package/dist/ExitClass.mjs +0 -59
- package/dist/ExitClass.schema-BmOIK5zT.d.mts +0 -18
- package/dist/Ignorer-BUY68FZQ.d.mts +0 -21
- package/dist/Ignorer.d.mts +0 -2
- package/dist/Ignorer.mjs +0 -7
- package/dist/Metrics.d.mts +0 -7
- package/dist/Metrics.mjs +0 -79
- package/dist/Metrics.schema-BaVd6rXs.d.mts +0 -50
- package/dist/Metrics.schema-DZdoc46X.mjs +0 -27
- package/dist/Module-DHKEMAFI.d.mts +0 -31
- package/dist/Module.d.mts +0 -2
- package/dist/Module.mjs +0 -16
- package/dist/Mutant-Ci25PsWA.d.mts +0 -351
- package/dist/Mutant.d.mts +0 -3
- package/dist/Mutant.mjs +0 -90
- package/dist/Mutant.schema-H3r0ho_R.mjs +0 -34
- package/dist/Plugin-BtItMchw.d.mts +0 -153
- package/dist/Plugin-DXFdJP1l.mjs +0 -105
- package/dist/Plugin.d.mts +0 -2
- package/dist/Plugin.mjs +0 -2
- package/dist/Report.d.mts +0 -2
- package/dist/Report.mjs +0 -98
- package/dist/Report.schema-_buHXdnZ.d.mts +0 -251
- package/dist/Reporter-BWTwlSJf.mjs +0 -25
- package/dist/Reporter-C7ysGxqX.d.mts +0 -16
- package/dist/Reporter.d.mts +0 -4
- package/dist/Reporter.mjs +0 -3
- package/dist/ReporterEvent.d.mts +0 -2
- package/dist/ReporterEvent.mjs +0 -57
- package/dist/ReporterEvent.schema-BhtyYCZo.d.mts +0 -189
- package/dist/Run-BUpNSmcM.d.mts +0 -172
- package/dist/Run.d.mts +0 -2
- package/dist/Run.mjs +0 -56
- package/dist/Run.schema-DZve3k2m.mjs +0 -138
- package/dist/Schema-4oCWQ0P0.d.mts +0 -158
- package/dist/Schema-CMKdWMUN.mjs +0 -244
- package/dist/Schema.d.mts +0 -2
- package/dist/Schema.mjs +0 -2
- package/dist/TestRunner.d.mts +0 -2
- package/dist/TestRunner.mjs +0 -57
- package/dist/TestRunner.schema-Dy0Wdro2.mjs +0 -119
- package/dist/output-file.d.mts +0 -8
- package/dist/output-file.mjs +0 -8
- package/dist/provided-options.d.mts +0 -16
- package/dist/provided-options.mjs +0 -1
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import { t as mergeConfig } from "./merge-config-BrrNQWje.mjs";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as FileSystem from "effect/FileSystem";
|
|
4
|
+
import * as Path from "effect/Path";
|
|
5
|
+
import { StrykerOptionsSchema, TestRunnerFailed, TestRunnerRpcs, WorkerEntryUrl, WorkerPluginKind, toMutantRunResult } from "@systemfsoftware/stryker-js-plugin-interface";
|
|
6
|
+
import * as Clock from "effect/Clock";
|
|
7
|
+
import * as Config from "effect/Config";
|
|
8
|
+
import * as Context from "effect/Context";
|
|
9
|
+
import * as Layer from "effect/Layer";
|
|
10
|
+
import * as Match from "effect/Match";
|
|
11
|
+
import * as Option from "effect/Option";
|
|
12
|
+
import * as Result from "effect/Result";
|
|
13
|
+
import * as S from "effect/Schema";
|
|
14
|
+
import * as Stream from "effect/Stream";
|
|
15
|
+
import { INSTRUMENTER_CONSTANTS } from "@systemfsoftware/stryker-js-instrumenter/mutants";
|
|
16
|
+
import * as Array$1 from "effect/Array";
|
|
17
|
+
import * as Cause from "effect/Cause";
|
|
18
|
+
import "effect/Exit";
|
|
19
|
+
import * as Predicate from "effect/Predicate";
|
|
20
|
+
import * as Duration from "effect/Duration";
|
|
21
|
+
import { Schema } from "effect";
|
|
22
|
+
import * as HashMap from "effect/HashMap";
|
|
23
|
+
import * as HashSet from "effect/HashSet";
|
|
24
|
+
import { Minimatch } from "minimatch";
|
|
25
|
+
import { encodeWorkerOptions, layerTraceContextClient } from "@systemfsoftware/stryker-js-plugin-runtime";
|
|
26
|
+
import * as Ref from "effect/Ref";
|
|
27
|
+
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
28
|
+
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
|
|
29
|
+
import { NodeFileSystem, NodePath, NodeSocket, NodeStdio } from "@effect/platform-node";
|
|
30
|
+
import * as NodeChildProcessSpawner from "@effect/platform-node-shared/NodeChildProcessSpawner";
|
|
31
|
+
import * as NodeCrypto from "@effect/platform-node-shared/NodeCrypto";
|
|
32
|
+
import * as Crypto from "effect/Crypto";
|
|
33
|
+
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
|
34
|
+
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
|
35
|
+
import * as Schedule from "effect/Schedule";
|
|
36
|
+
//#region src/Run.schema.ts
|
|
37
|
+
const TypeId = "~stryker/mutation-run/StageError";
|
|
38
|
+
var StageError = class extends S.TaggedError(TypeId)("StageError", {
|
|
39
|
+
stage: S.Literals([
|
|
40
|
+
"prepare",
|
|
41
|
+
"instrument",
|
|
42
|
+
"dryRun",
|
|
43
|
+
"dryRunNoTests",
|
|
44
|
+
"mutationTest"
|
|
45
|
+
]),
|
|
46
|
+
reason: S.String,
|
|
47
|
+
cause: S.optional(S.Defect()),
|
|
48
|
+
command: S.optional(S.String)
|
|
49
|
+
}) {
|
|
50
|
+
[TypeId] = TypeId;
|
|
51
|
+
get exitClass() {
|
|
52
|
+
return STAGE_PRESENTATION[this.stage].exitClass;
|
|
53
|
+
}
|
|
54
|
+
get message() {
|
|
55
|
+
const failure = `${STAGE_PRESENTATION[this.stage].label} failed: ${this.reason}`;
|
|
56
|
+
return Match.value(this.command).pipe(Match.when(Match.nonEmptyString, (present) => `${failure} (command: ${present})`), Match.orElse(() => failure));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const STAGE_PRESENTATION = {
|
|
60
|
+
prepare: {
|
|
61
|
+
label: "Prepare",
|
|
62
|
+
exitClass: "ConfigError"
|
|
63
|
+
},
|
|
64
|
+
instrument: {
|
|
65
|
+
label: "Instrument",
|
|
66
|
+
exitClass: "RuntimeError"
|
|
67
|
+
},
|
|
68
|
+
dryRun: {
|
|
69
|
+
label: "Dry run",
|
|
70
|
+
exitClass: "RuntimeError"
|
|
71
|
+
},
|
|
72
|
+
dryRunNoTests: {
|
|
73
|
+
label: "Dry run",
|
|
74
|
+
exitClass: "ConfigError"
|
|
75
|
+
},
|
|
76
|
+
mutationTest: {
|
|
77
|
+
label: "Mutation testing",
|
|
78
|
+
exitClass: "RuntimeError"
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
var PrepareError = class extends S.TaggedError()("PrepareError", {
|
|
82
|
+
stage: S.Literal("prepare"),
|
|
83
|
+
reason: S.String
|
|
84
|
+
}) {};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/Config.schema.ts
|
|
87
|
+
const ConfigDocumentSchema = S.Record(S.String, S.Unknown);
|
|
88
|
+
const ImportedModuleSchema = S.Struct({ default: S.optional(S.Unknown) });
|
|
89
|
+
var ConfigFileNotFoundError = class extends S.TaggedError()("ConfigFileNotFoundError", { file: S.String }) {
|
|
90
|
+
exitClass = "ConfigError";
|
|
91
|
+
};
|
|
92
|
+
var ConfigFileUnsupportedError = class extends S.TaggedError()("ConfigFileUnsupportedError", {
|
|
93
|
+
file: S.String,
|
|
94
|
+
hint: S.String
|
|
95
|
+
}) {
|
|
96
|
+
exitClass = "ConfigError";
|
|
97
|
+
get message() {
|
|
98
|
+
return this.hint;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
var ConfigFileUnreadableError = class extends S.TaggedError()("ConfigFileUnreadableError", {
|
|
102
|
+
file: S.String,
|
|
103
|
+
cause: S.Unknown
|
|
104
|
+
}) {
|
|
105
|
+
exitClass = "ConfigError";
|
|
106
|
+
};
|
|
107
|
+
var ConfigFileInvalidError = class extends S.TaggedError()("ConfigFileInvalidError", {
|
|
108
|
+
file: S.String,
|
|
109
|
+
cause: S.Unknown
|
|
110
|
+
}) {
|
|
111
|
+
exitClass = "ConfigError";
|
|
112
|
+
};
|
|
113
|
+
var ConfigError = class extends S.TaggedError()("ConfigError", { message: S.String }) {
|
|
114
|
+
exitClass = "ConfigError";
|
|
115
|
+
};
|
|
116
|
+
var ReadConfigCommand = class extends S.TaggedClass()("ReadConfigCommand", {
|
|
117
|
+
cliOptions: S.Record(S.String, S.Unknown),
|
|
118
|
+
basePath: S.String
|
|
119
|
+
}) {};
|
|
120
|
+
var MergeCommand = class extends S.TaggedClass()("MergeCommand", {
|
|
121
|
+
base: S.Record(S.String, S.Unknown),
|
|
122
|
+
overrides: S.Record(S.String, S.Unknown)
|
|
123
|
+
}) {};
|
|
124
|
+
var MergeResult = class extends S.TaggedClass()("MergeResult", { merged: S.Record(S.String, S.Unknown) }) {};
|
|
125
|
+
const survivorsPriorReport = S.optionalKey(S.String.pipe(S.annotate({ description: "The path of the prior mutation report a --survivors run admits against and re-tests the survivors of. Defaults to \"reports/mutation-report.json\" when unset. Deliberately has no default: a default would be injected into every resolved options object and written into every report, poisoning the KTD7 marker that identifies a report produced by a survivors run." })));
|
|
126
|
+
const extendsPropertySchema = S.optionalKey(S.String.pipe(S.annotate({ description: "Path to another stryker config file whose options merge underneath this one. Resolved relative to this file. A child scalar or array replaces the inherited value; a child object merges one level deep; a child key set to null deletes the inherited key. Inheritance chains are not rewritten, so an inherited relative path value still resolves against the working directory of the run that reads it." })));
|
|
127
|
+
const forkOptionsSchema = S.StructWithRest(S.Struct({
|
|
128
|
+
...StrykerOptionsSchema.schema.fields,
|
|
129
|
+
survivorsPriorReport,
|
|
130
|
+
extends: extendsPropertySchema
|
|
131
|
+
}), [S.Record(S.String, S.Unknown)]);
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/Project.ignore.ts
|
|
134
|
+
const ALWAYS_IGNORE = Object.freeze([
|
|
135
|
+
"node_modules",
|
|
136
|
+
".git",
|
|
137
|
+
"*.tsbuildinfo",
|
|
138
|
+
"/stryker.log",
|
|
139
|
+
".next",
|
|
140
|
+
".nuxt",
|
|
141
|
+
".svelte-kit"
|
|
142
|
+
]);
|
|
143
|
+
const MUTATION_RANGE_REGEX = /(.*?):((\d+)(?::(\d+))?-(\d+)(?::(\d+))?)$/;
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/stryker-error.schema.ts
|
|
146
|
+
var StrykerError = class extends Schema.TaggedError()("StrykerError", {
|
|
147
|
+
message: Schema.String,
|
|
148
|
+
cause: Schema.optional(Schema.Unknown)
|
|
149
|
+
}) {};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/VmRunner.ts
|
|
152
|
+
var VmRunner = class extends Context.Service()("@systemfsoftware/stryker-js/VmRunner") {};
|
|
153
|
+
const vmRunnerName = "vm";
|
|
154
|
+
const ALL_TESTS_NAME = "All tests";
|
|
155
|
+
const isVmRunner = (name) => name.toLowerCase() === "vm";
|
|
156
|
+
const vmRunnerCapabilities = { reloadEnvironment: true };
|
|
157
|
+
const FALLBACK_FILE_NAME = "stryker-vm-tests.js";
|
|
158
|
+
const errorText = (error) => Match.value(error).pipe(Match.when(Match.instanceOf(Error), (failure) => failure.message), Match.orElse((value) => String(value)));
|
|
159
|
+
const compileFailure = (file, cause) => TestRunnerFailed.make({
|
|
160
|
+
runnerName: "vm",
|
|
161
|
+
phase: "init",
|
|
162
|
+
cause: `Could not compile "${file}" for the in-memory runner: ${errorText(cause)}`
|
|
163
|
+
});
|
|
164
|
+
const compileTests = (platform, fs, testFiles) => Effect.gen(function* () {
|
|
165
|
+
const sources = yield* Effect.forEach(testFiles, (file) => fs.readFileString(file).pipe(Effect.mapError((cause) => compileFailure(file, cause)), Effect.flatMap((source) => Effect.try({
|
|
166
|
+
try: () => platform.module.stripTypeScriptTypes(source, { mode: "transform" }),
|
|
167
|
+
catch: (cause) => compileFailure(file, cause)
|
|
168
|
+
}))));
|
|
169
|
+
const fileName = Option.getOrElse(Option.fromUndefinedOr(testFiles.at(0)), () => FALLBACK_FILE_NAME);
|
|
170
|
+
return {
|
|
171
|
+
fileName,
|
|
172
|
+
script: yield* Effect.try({
|
|
173
|
+
try: () => new platform.vm.Script(sources.join("\n;\n"), { filename: fileName }),
|
|
174
|
+
catch: (cause) => compileFailure(fileName, cause)
|
|
175
|
+
})
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
const sandboxFor = (platform, fileName, activeMutantId) => {
|
|
179
|
+
const namespace = Match.value(activeMutantId).pipe(Match.when(Match.string, (active) => ({ [INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT]: active })), Match.orElse(() => ({})));
|
|
180
|
+
const moduleExports = {};
|
|
181
|
+
const moduleObj = { exports: moduleExports };
|
|
182
|
+
const sandbox = {
|
|
183
|
+
...globalThis,
|
|
184
|
+
[INSTRUMENTER_CONSTANTS.NAMESPACE]: namespace,
|
|
185
|
+
require: platform.module.createRequire(fileName),
|
|
186
|
+
module: moduleObj,
|
|
187
|
+
exports: moduleExports,
|
|
188
|
+
__filename: fileName
|
|
189
|
+
};
|
|
190
|
+
sandbox["global"] = sandbox;
|
|
191
|
+
sandbox["globalThis"] = sandbox;
|
|
192
|
+
return sandbox;
|
|
193
|
+
};
|
|
194
|
+
const resultFromRun = (failureMessage, timeSpentMs) => Match.value(failureMessage).pipe(Match.when(Match.string, (failure) => ({
|
|
195
|
+
status: "complete",
|
|
196
|
+
tests: [{
|
|
197
|
+
id: "all",
|
|
198
|
+
name: ALL_TESTS_NAME,
|
|
199
|
+
status: "failed",
|
|
200
|
+
failureMessage: failure,
|
|
201
|
+
timeSpentMs
|
|
202
|
+
}]
|
|
203
|
+
})), Match.orElse(() => ({
|
|
204
|
+
status: "complete",
|
|
205
|
+
tests: [{
|
|
206
|
+
id: "all",
|
|
207
|
+
name: ALL_TESTS_NAME,
|
|
208
|
+
status: "success",
|
|
209
|
+
timeSpentMs
|
|
210
|
+
}]
|
|
211
|
+
})));
|
|
212
|
+
const runInFreshContext = (platform, compiled, activeMutantId) => Effect.sync(() => {
|
|
213
|
+
const context = platform.vm.createContext(sandboxFor(platform, compiled.fileName, activeMutantId));
|
|
214
|
+
try {
|
|
215
|
+
compiled.script.runInContext(context);
|
|
216
|
+
return;
|
|
217
|
+
} catch (cause) {
|
|
218
|
+
return errorText(cause);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const runOnce = (platform, compiled, activeMutantId) => Effect.gen(function* () {
|
|
222
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
223
|
+
const failure = yield* runInFreshContext(platform, compiled, activeMutantId);
|
|
224
|
+
const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt;
|
|
225
|
+
return resultFromRun(failure, elapsedMs);
|
|
226
|
+
});
|
|
227
|
+
const vmTestRunner = (config) => Effect.gen(function* () {
|
|
228
|
+
const platform = yield* VmRunner;
|
|
229
|
+
const fs = yield* FileSystem.FileSystem;
|
|
230
|
+
const compiled = yield* Ref.make(Option.none());
|
|
231
|
+
const compiledTests = (testFiles) => Effect.gen(function* () {
|
|
232
|
+
const cached = yield* Ref.get(compiled);
|
|
233
|
+
if (Option.isSome(cached)) return cached.value;
|
|
234
|
+
const tests = yield* compileTests(platform, fs, testFiles);
|
|
235
|
+
yield* Ref.set(compiled, Option.some(tests));
|
|
236
|
+
return tests;
|
|
237
|
+
});
|
|
238
|
+
const run = (testFiles, activeMutantId) => compiledTests(testFiles).pipe(Effect.flatMap((tests) => runOnce(platform, tests, activeMutantId)));
|
|
239
|
+
const testFilesOf = (override) => Match.value(config.testFiles).pipe(Match.when((files) => files.length > 0, (files) => files), Match.orElse(() => override ?? []));
|
|
240
|
+
return {
|
|
241
|
+
capabilities: Effect.succeed(vmRunnerCapabilities),
|
|
242
|
+
init: Effect.void,
|
|
243
|
+
dryRun: (options) => run(testFilesOf(options.testFiles), void 0),
|
|
244
|
+
mutantRun: (options) => run(config.testFiles, options.activeMutant.id).pipe(Effect.map((result) => toMutantRunResult(result, true)), Effect.catchTag("TestRunnerFailed", (failure) => Effect.succeed(toMutantRunResult({
|
|
245
|
+
status: "error",
|
|
246
|
+
errorMessage: failure.cause
|
|
247
|
+
}, true))))
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
Schema.TaggedError()("WorkerMethodError", {
|
|
251
|
+
message: Schema.String,
|
|
252
|
+
name: Schema.optional(Schema.String),
|
|
253
|
+
stack: Schema.optional(Schema.String)
|
|
254
|
+
});
|
|
255
|
+
/**
|
|
256
|
+
* A process identifier. `S.Int` rather than `S.Number` because the plain number
|
|
257
|
+
* domain admits `NaN` and the infinities, and a pid is none of those.
|
|
258
|
+
*/
|
|
259
|
+
const ProcessId = Schema.Int;
|
|
260
|
+
/**
|
|
261
|
+
* How a child process ended.
|
|
262
|
+
*/
|
|
263
|
+
const ChildExit = Schema.Union([Schema.Struct({
|
|
264
|
+
_tag: Schema.Literals(["Code"]),
|
|
265
|
+
code: Schema.Int
|
|
266
|
+
}), Schema.Struct({
|
|
267
|
+
_tag: Schema.Literals(["Signal"]),
|
|
268
|
+
signal: Schema.String
|
|
269
|
+
})]);
|
|
270
|
+
/**
|
|
271
|
+
* The child process hosting a worker ended when it was not supposed to.
|
|
272
|
+
*/
|
|
273
|
+
var ChildProcessCrashedError = class extends Schema.TaggedError()("ChildProcessCrashedError", {
|
|
274
|
+
pid: ProcessId,
|
|
275
|
+
exit: ChildExit,
|
|
276
|
+
cause: Schema.optional(Schema.String)
|
|
277
|
+
}) {
|
|
278
|
+
exitClass = "InternalError";
|
|
279
|
+
};
|
|
280
|
+
var OutOfMemoryError = class extends Schema.TaggedError()("OutOfMemoryError", {
|
|
281
|
+
pid: ProcessId,
|
|
282
|
+
exitCode: Schema.Int
|
|
283
|
+
}) {
|
|
284
|
+
exitClass = "RuntimeError";
|
|
285
|
+
};
|
|
286
|
+
var WorkerBootTimeoutError = class extends Schema.TaggedError()("WorkerBootTimeoutError", { pid: ProcessId }) {
|
|
287
|
+
exitClass = "InternalError";
|
|
288
|
+
};
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/Worker.ts
|
|
291
|
+
var IdGenerator = class extends Context.Service()("@systemfsoftware/stryker-js/Worker/IdGenerator") {};
|
|
292
|
+
const makeIdGenerator = Effect.gen(function* () {
|
|
293
|
+
const ref = yield* Ref.make(0);
|
|
294
|
+
return { next: Ref.getAndUpdate(ref, (n) => n + 1) };
|
|
295
|
+
});
|
|
296
|
+
const layer = Layer.effect(IdGenerator)(makeIdGenerator);
|
|
297
|
+
const ASSUMED_PARALLELISM = 4;
|
|
298
|
+
const reportedParallelism = () => {
|
|
299
|
+
if (typeof globalThis.navigator === "undefined") return void 0;
|
|
300
|
+
return globalThis.navigator.hardwareConcurrency;
|
|
301
|
+
};
|
|
302
|
+
const getAvailableParallelism = () => {
|
|
303
|
+
const reported = reportedParallelism();
|
|
304
|
+
if (typeof reported !== "number") return ASSUMED_PARALLELISM;
|
|
305
|
+
return reported;
|
|
306
|
+
};
|
|
307
|
+
const PERCENTAGE_OPTION = /^(100|[1-9]?[0-9])%$/;
|
|
308
|
+
const percentageOf = (text) => Option.fromNullishOr(PERCENTAGE_OPTION.exec(text)?.[1]).pipe(Option.map((digits) => Number.parseInt(digits, 10)), Option.getOrUndefined);
|
|
309
|
+
const defaultedTotal = (availableParallelism) => Match.value(availableParallelism > 4).pipe(Match.when(true, () => availableParallelism - 1), Match.orElse(() => availableParallelism));
|
|
310
|
+
const unsetDetails = (availableParallelism) => ({
|
|
311
|
+
total: defaultedTotal(availableParallelism),
|
|
312
|
+
isPercentage: false
|
|
313
|
+
});
|
|
314
|
+
const percentageDetails = (text, availableParallelism) => Match.value(percentageOf(text)).pipe(Match.when(Predicate.isNumber, (percentage) => ({
|
|
315
|
+
total: Math.max(1, Math.round(availableParallelism * percentage / 100)),
|
|
316
|
+
isPercentage: true
|
|
317
|
+
})), Match.orElse(() => unsetDetails(availableParallelism)));
|
|
318
|
+
const computeTotalConcurrencyDetails = (concurrencyOption, availableParallelism) => Match.value(concurrencyOption).pipe(Match.when(Predicate.isString, (text) => percentageDetails(text, availableParallelism)), Match.when(Predicate.isNumber, (total) => ({
|
|
319
|
+
total,
|
|
320
|
+
isPercentage: false
|
|
321
|
+
})), Match.orElse(() => unsetDetails(availableParallelism)));
|
|
322
|
+
const splitConcurrency = (total, checkerCount) => {
|
|
323
|
+
if (checkerCount > 0) return {
|
|
324
|
+
checkers: Math.max(Math.ceil(total / 2), 1),
|
|
325
|
+
testRunners: Math.max(Math.floor(total / 2), 1)
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
testRunners: total,
|
|
329
|
+
checkers: 0
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const announcePercentage = (resolved) => Match.value(resolved.isPercentage).pipe(Match.when(true, () => Effect.logDebug(`Computed concurrency ${resolved.total} from "${resolved.option}" based on ${resolved.availableParallelism} available parallelism.`)), Match.orElse(() => Effect.void));
|
|
333
|
+
const announceProcesses = (split) => Match.value(split.checkers > 0).pipe(Match.when(true, () => Effect.logInfo(`Creating ${split.checkers} checker process(es) and ${split.testRunners} test runner process(es).`)), Match.orElse(() => Effect.logInfo(`Creating ${split.testRunners} test runner process(es).`)));
|
|
334
|
+
const makeConcurrency = (options) => Effect.gen(function* () {
|
|
335
|
+
const availableParallelism = yield* Effect.sync(getAvailableParallelism);
|
|
336
|
+
const { total, isPercentage } = computeTotalConcurrencyDetails(options.concurrency, availableParallelism);
|
|
337
|
+
yield* announcePercentage({
|
|
338
|
+
option: options.concurrency,
|
|
339
|
+
total,
|
|
340
|
+
isPercentage,
|
|
341
|
+
availableParallelism
|
|
342
|
+
});
|
|
343
|
+
const result = splitConcurrency(total, options.checkers.length);
|
|
344
|
+
yield* announceProcesses(result);
|
|
345
|
+
return result;
|
|
346
|
+
});
|
|
347
|
+
const OUT_OF_MEMORY_EXIT_CODES = [134, 137];
|
|
348
|
+
const isOutOfMemoryExit = (exitCode) => OUT_OF_MEMORY_EXIT_CODES.includes(exitCode);
|
|
349
|
+
const classifyWorkerExit = (pid, exitCode) => Match.value(exitCode).pipe(Match.when(isOutOfMemoryExit, () => OutOfMemoryError.make({
|
|
350
|
+
pid,
|
|
351
|
+
exitCode
|
|
352
|
+
})), Match.orElse(() => ChildProcessCrashedError.make({
|
|
353
|
+
pid,
|
|
354
|
+
exit: {
|
|
355
|
+
_tag: "Code",
|
|
356
|
+
code: exitCode
|
|
357
|
+
},
|
|
358
|
+
cause: "worker exited before it accepted the RPC connection"
|
|
359
|
+
})));
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/WorkerLauncher.ts
|
|
362
|
+
const connectRetry = Schedule.max([Schedule.spaced(50), Schedule.recurs(100)]);
|
|
363
|
+
var WorkerLauncher = class extends Context.Service()("@systemfsoftware/stryker-js/WorkerLauncher") {};
|
|
364
|
+
const makeWorkerClient = (params) => Effect.gen(function* () {
|
|
365
|
+
const worker = yield* (yield* WorkerLauncher).spawn({
|
|
366
|
+
entrypoint: params.entrypoint,
|
|
367
|
+
workingDirectory: params.workingDirectory,
|
|
368
|
+
execArgv: params.execArgv,
|
|
369
|
+
optionsJson: params.optionsJson,
|
|
370
|
+
tempDirPrefix: params.tempDirPrefix,
|
|
371
|
+
env: params.env
|
|
372
|
+
});
|
|
373
|
+
const protocol = yield* Layer.build(worker.clientLayer).pipe(Effect.retry(connectRetry), Effect.raceFirst(worker.exited), Effect.catchTag("SocketError", () => Effect.fail(WorkerBootTimeoutError.make({ pid: worker.pid }))));
|
|
374
|
+
return yield* RpcClient.make(params.rpcs).pipe(Effect.provideContext(protocol), Effect.provide(layerTraceContextClient));
|
|
375
|
+
});
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/platform/node.ts
|
|
378
|
+
const restrictToOwnerOrWarn = (fs, file) => fs.chmod(file, 384).pipe(Effect.catchTag("PlatformError", (cause) => Effect.logWarning(`Could not restrict "${file}" to its owner; the worker directory's own mode still protects it.`).pipe(Effect.annotateLogs("cause", cause))));
|
|
379
|
+
const restrictSocketToOwnerOrWarn = (fs, socketPath) => Match.value(process.platform).pipe(Match.when("win32", () => Effect.void), Match.orElse(() => restrictToOwnerOrWarn(fs, socketPath)));
|
|
380
|
+
/**
|
|
381
|
+
* The Node worker launcher: spawn a worker child with this runtime's
|
|
382
|
+
* executable, host the RPC server's address as a `net` `path` endpoint (a
|
|
383
|
+
* socket file in the worker directory on POSIX, a same-user named pipe on
|
|
384
|
+
* Windows), and connect the NDJSON protocol client over `NodeSocket`.
|
|
385
|
+
*/
|
|
386
|
+
const nodeWorkerLauncherLayer = Layer.effect(WorkerLauncher, Effect.gen(function* () {
|
|
387
|
+
const crypto = yield* Crypto.Crypto;
|
|
388
|
+
const fs = yield* FileSystem.FileSystem;
|
|
389
|
+
const path = yield* Path.Path;
|
|
390
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
391
|
+
return { spawn: (params) => Effect.gen(function* () {
|
|
392
|
+
const workerDir = yield* fs.makeTempDirectoryScoped({ prefix: params.tempDirPrefix });
|
|
393
|
+
const workerId = yield* crypto.randomUUIDv4;
|
|
394
|
+
const socketPath = Match.value(process.platform).pipe(Match.when("win32", () => `\\\\.\\pipe\\stryker-worker-${workerId}`), Match.orElse(() => path.join(workerDir, "worker.sock")));
|
|
395
|
+
const optionsFile = path.join(workerDir, "options.json");
|
|
396
|
+
yield* fs.writeFileString(optionsFile, params.optionsJson);
|
|
397
|
+
yield* restrictToOwnerOrWarn(fs, optionsFile);
|
|
398
|
+
const entrypointPath = yield* path.fromFileUrl(new URL(params.entrypoint));
|
|
399
|
+
const handle = yield* ChildProcess.make(process.execPath, [...params.execArgv, entrypointPath], {
|
|
400
|
+
cwd: params.workingDirectory,
|
|
401
|
+
extendEnv: true,
|
|
402
|
+
env: {
|
|
403
|
+
STRYKER_WORKER_DIR: workerDir,
|
|
404
|
+
STRYKER_SOCKET: socketPath,
|
|
405
|
+
...params.env
|
|
406
|
+
},
|
|
407
|
+
stderr: "inherit"
|
|
408
|
+
}).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
|
|
409
|
+
const clientLayer = RpcClient.layerProtocolSocket({ retryTransientErrors: true }).pipe(Layer.provide(NodeSocket.layerNet({ path: socketPath })), Layer.provide(RpcSerialization.layerNdjson), Layer.tap(() => restrictSocketToOwnerOrWarn(fs, socketPath)));
|
|
410
|
+
const exited = handle.exitCode.pipe(Effect.orDie, Effect.flatMap((exitCode) => Effect.fail(classifyWorkerExit(Number(handle.pid), exitCode))));
|
|
411
|
+
return {
|
|
412
|
+
pid: Number(handle.pid),
|
|
413
|
+
clientLayer,
|
|
414
|
+
exited
|
|
415
|
+
};
|
|
416
|
+
}).pipe(Effect.catchIf(S.is(ChildProcessCrashedError), (error) => Effect.fail(error), () => Effect.fail(ChildProcessCrashedError.make({
|
|
417
|
+
pid: 0,
|
|
418
|
+
exit: {
|
|
419
|
+
_tag: "Code",
|
|
420
|
+
code: 1
|
|
421
|
+
},
|
|
422
|
+
cause: "worker spawn failed"
|
|
423
|
+
})))) };
|
|
424
|
+
}));
|
|
425
|
+
const nodeFsPathLayer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
|
|
426
|
+
const nodeSpawnerLayer = NodeChildProcessSpawner.layer.pipe(Layer.provide(nodeFsPathLayer));
|
|
427
|
+
const nodeBase = Layer.mergeAll(nodeFsPathLayer, nodeSpawnerLayer, NodeStdio.layer);
|
|
428
|
+
const nodePlatformLayer = Layer.mergeAll(nodeWorkerLauncherLayer.pipe(Layer.provide(Layer.merge(nodeBase, NodeCrypto.layer))), nodeBase);
|
|
429
|
+
/**
|
|
430
|
+
* The in-memory runner's platform: the V8 sandbox module and the module
|
|
431
|
+
* builtin that strips TypeScript and resolves a sandbox's `require`.
|
|
432
|
+
*
|
|
433
|
+
* Reached through `process.getBuiltinModule` rather than an `import` because a
|
|
434
|
+
* Node builtin import is forbidden in product code; the call is wrapped in
|
|
435
|
+
* `Effect.promise` so a platform without `node:vm` fails as a defect — the
|
|
436
|
+
* runner has no fallback to fall back to.
|
|
437
|
+
*/
|
|
438
|
+
const nodeVmPlatformLayer = Layer.effect(VmRunner, Effect.sync(() => ({
|
|
439
|
+
module: process.getBuiltinModule("node:module"),
|
|
440
|
+
vm: process.getBuiltinModule("node:vm")
|
|
441
|
+
})));
|
|
442
|
+
S.TaggedError()("CommandRunnerUnsupportedOption", { option: S.String });
|
|
443
|
+
S.Struct({
|
|
444
|
+
static: S.Record(S.String, S.Finite),
|
|
445
|
+
perTest: S.Record(S.String, S.Record(S.String, S.Finite))
|
|
446
|
+
});
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/TestRunner.ts
|
|
449
|
+
/**
|
|
450
|
+
* The test runner capability — spawning, timeout, retry, reuse and environment
|
|
451
|
+
* decisions for the engine's test execution.
|
|
452
|
+
*/
|
|
453
|
+
const toRunnerBootFailure = (runnerName) => (error) => Match.value(error).pipe(Match.tag("WorkerBootTimeoutError", (timeout) => TestRunnerFailed.make({
|
|
454
|
+
runnerName,
|
|
455
|
+
phase: "init",
|
|
456
|
+
cause: `Worker ${timeout.pid} did not accept the RPC connection before its boot window closed; its stderr above carries the reason`
|
|
457
|
+
})), Match.tag("ChildProcessCrashedError", (crashed) => crashed), Match.tag("OutOfMemoryError", (outOfMemory) => outOfMemory), Match.exhaustive);
|
|
458
|
+
const toRunnerFailure = (runnerName, phase) => (error) => Match.value(error).pipe(Match.tag("TestRunnerFailed", (e) => e), Match.orElse((e) => TestRunnerFailed.make({
|
|
459
|
+
runnerName,
|
|
460
|
+
phase,
|
|
461
|
+
cause: e.message
|
|
462
|
+
})));
|
|
463
|
+
/**
|
|
464
|
+
* A test runner that runs in a child process.
|
|
465
|
+
*
|
|
466
|
+
* Spawning happens **once**, here, and the child's teardown is a finalizer on the
|
|
467
|
+
* scope this Effect is acquired in — which is the scope `Pool.make` opens for one
|
|
468
|
+
* worker. So a worker lives as long as its pool slot, and each `dryRun` or
|
|
469
|
+
* `mutantRun` only sends a message to a process that is already up.
|
|
470
|
+
*
|
|
471
|
+
* The scope boundary is the whole correctness question. Putting it inside each
|
|
472
|
+
* method type-checks identically and spawns a Node process per mutant, which
|
|
473
|
+
* makes the pool hold nothing and pays worker startup thousands of times.
|
|
474
|
+
*
|
|
475
|
+
* A failed spawn stays a typed failure rather than a defect, because
|
|
476
|
+
* `Pool.invalidate` and the crash-retry combinator can only act on a failure they
|
|
477
|
+
* can see.
|
|
478
|
+
*/
|
|
479
|
+
const makeChildProcessTestRunner = (params) => Effect.gen(function* () {
|
|
480
|
+
const runnerName = params.options.testRunner;
|
|
481
|
+
const optionsJson = yield* encodeWorkerOptions(params.options);
|
|
482
|
+
const client = yield* makeWorkerClient({
|
|
483
|
+
rpcs: TestRunnerRpcs,
|
|
484
|
+
entrypoint: params.workerEntrypoint,
|
|
485
|
+
workingDirectory: params.sandboxWorkingDirectory,
|
|
486
|
+
execArgv: [...params.options.testRunnerNodeArgs],
|
|
487
|
+
optionsJson,
|
|
488
|
+
tempDirPrefix: "stryker-test-runner-",
|
|
489
|
+
env: {
|
|
490
|
+
NODE_ENV: "test",
|
|
491
|
+
VITEST: "1",
|
|
492
|
+
STRYKER_SANDBOX_DIR: params.sandboxWorkingDirectory
|
|
493
|
+
}
|
|
494
|
+
}).pipe(Effect.mapError(toRunnerBootFailure(runnerName)));
|
|
495
|
+
return {
|
|
496
|
+
capabilities: client.capabilities().pipe(Effect.mapError(toRunnerFailure(runnerName, "capabilities"))),
|
|
497
|
+
init: Effect.void,
|
|
498
|
+
dryRun: (options) => client.dryRun({ options }).pipe(Effect.mapError(toRunnerFailure(runnerName, "dryRun"))),
|
|
499
|
+
mutantRun: (options) => client.mutantRun({ options }).pipe(Effect.mapError(toRunnerFailure(runnerName, "mutantRun")))
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
/**
|
|
503
|
+
* Report a run that outlives its timeout as a timed-out result.
|
|
504
|
+
*
|
|
505
|
+
* `Effect.timeoutOrElse` is the race, and retiring the worker belongs to the
|
|
506
|
+
* pool, which invalidates it. An interrupted `Effect` is cancelled, where a
|
|
507
|
+
* losing `Promise` was not.
|
|
508
|
+
*/
|
|
509
|
+
const withTimeout = (inner) => ({
|
|
510
|
+
...inner,
|
|
511
|
+
dryRun: (options) => {
|
|
512
|
+
const policy = Effect.timeoutOrElse({
|
|
513
|
+
duration: Duration.millis(options.timeout),
|
|
514
|
+
orElse: () => Effect.succeed({ status: "timeout" })
|
|
515
|
+
});
|
|
516
|
+
return inner.dryRun(options).pipe(policy);
|
|
517
|
+
},
|
|
518
|
+
mutantRun: (options) => {
|
|
519
|
+
const policy = Effect.timeoutOrElse({
|
|
520
|
+
duration: Duration.millis(options.timeout),
|
|
521
|
+
orElse: () => Effect.succeed({ status: "timeout" })
|
|
522
|
+
});
|
|
523
|
+
return inner.mutantRun(options).pipe(policy);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
/**
|
|
527
|
+
* Retry a run whose runner crashed, and report the crash as a result once the
|
|
528
|
+
* attempts are spent.
|
|
529
|
+
*
|
|
530
|
+
* Every failure is retried, not only out-of-memory; the out-of-memory check
|
|
531
|
+
* only changes the log message. On exhaustion the combinator produces a
|
|
532
|
+
* `status: Error` result rather than failing, because one broken runner must
|
|
533
|
+
* not end the whole run. The exhausted message renders the `Cause`, which
|
|
534
|
+
* keeps the chain that led there.
|
|
535
|
+
*/
|
|
536
|
+
const withRetry = (inner) => {
|
|
537
|
+
const attempt = (run, onExhausted) => run.pipe(Effect.tapError((error) => Match.value(error).pipe(Match.when(S.is(OutOfMemoryError), (outOfMemory) => Effect.logInfo(`Test runner process [${outOfMemory.pid}] ran out of memory. That usually means the tests leak memory. Stryker restarts the process and carries on, but the run is slower for it.`)), Match.orElse(() => Effect.void))), Effect.retry({ times: 2 }), Effect.catchCause((cause) => Effect.succeed(onExhausted(`Test runner crashed. Tried 2 times to restart it without any luck. ${Cause.pretty(cause)}`))));
|
|
538
|
+
return {
|
|
539
|
+
...inner,
|
|
540
|
+
dryRun: (options) => attempt(inner.dryRun(options), (errorMessage) => ({
|
|
541
|
+
status: "error",
|
|
542
|
+
errorMessage
|
|
543
|
+
})),
|
|
544
|
+
mutantRun: (options) => attempt(inner.mutantRun(options), (errorMessage) => ({
|
|
545
|
+
status: "error",
|
|
546
|
+
errorMessage
|
|
547
|
+
}))
|
|
548
|
+
};
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* Retire a runner after a configured number of mutant runs.
|
|
552
|
+
*
|
|
553
|
+
* The count lives in a `Ref` the combinator closes over, so nothing outside can
|
|
554
|
+
* read or reset it. `retire` is supplied rather than being a `recover()` the
|
|
555
|
+
* runner performs on itself: the pool owns worker lifetime, so retiring one is
|
|
556
|
+
* `Pool.invalidate`, and a runner that restarts itself behind the pool's back
|
|
557
|
+
* would create two owners of one process.
|
|
558
|
+
*/
|
|
559
|
+
const withMaxReuse = (options, retire) => (inner) => Effect.gen(function* () {
|
|
560
|
+
const restartAfter = options.maxTestRunnerReuse;
|
|
561
|
+
if (restartAfter <= 0) return inner;
|
|
562
|
+
const runs = yield* Ref.make(0);
|
|
563
|
+
return {
|
|
564
|
+
...inner,
|
|
565
|
+
mutantRun: (runOptions) => {
|
|
566
|
+
const policy = (self) => Effect.gen(function* () {
|
|
567
|
+
if ((yield* Ref.updateAndGet(runs, (n) => n + 1)) > restartAfter) {
|
|
568
|
+
yield* retire;
|
|
569
|
+
yield* Ref.set(runs, 1);
|
|
570
|
+
}
|
|
571
|
+
return yield* self;
|
|
572
|
+
});
|
|
573
|
+
return policy(inner.mutantRun(runOptions));
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
});
|
|
577
|
+
const stateNeedsReload = (requested, current) => Match.value(requested).pipe(Match.when(true, () => current !== "pristine"), Match.orElse(() => current === "loaded-static-mutant"));
|
|
578
|
+
const reloadsEnvironment = (request) => request.canReload && stateNeedsReload(request.requested, request.current);
|
|
579
|
+
const retiresWorker = (request) => !request.canReload && request.current === "loaded-static-mutant";
|
|
580
|
+
const stateAfterRun = (requested) => Match.value(requested).pipe(Match.when(true, () => "loaded-static-mutant"), Match.orElse(() => "loaded"));
|
|
581
|
+
const environmentPlan = (request) => ({
|
|
582
|
+
reloadEnvironment: reloadsEnvironment(request),
|
|
583
|
+
retire: retiresWorker(request),
|
|
584
|
+
nextState: stateAfterRun(request.requested)
|
|
585
|
+
});
|
|
586
|
+
/**
|
|
587
|
+
* Decide whether the test environment must be reloaded before a mutant runs.
|
|
588
|
+
*
|
|
589
|
+
* The decision produces a new options value; the caller's options are not
|
|
590
|
+
* mutated, so a caller that reuses its own options does not read a value it
|
|
591
|
+
* never wrote.
|
|
592
|
+
*/
|
|
593
|
+
const withEnvironmentReload = (retire) => (inner) => Effect.gen(function* () {
|
|
594
|
+
const state = yield* Ref.make("pristine");
|
|
595
|
+
return {
|
|
596
|
+
...inner,
|
|
597
|
+
dryRun: (options) => {
|
|
598
|
+
const policy = (self) => Ref.set(state, "loaded").pipe(Effect.andThen(self));
|
|
599
|
+
return policy(inner.dryRun(options));
|
|
600
|
+
},
|
|
601
|
+
mutantRun: (options) => Effect.gen(function* () {
|
|
602
|
+
const current = yield* Ref.get(state);
|
|
603
|
+
const canReload = (yield* inner.capabilities).reloadEnvironment;
|
|
604
|
+
const plan = environmentPlan({
|
|
605
|
+
requested: options.reloadEnvironment,
|
|
606
|
+
current,
|
|
607
|
+
canReload
|
|
608
|
+
});
|
|
609
|
+
if (plan.retire) yield* retire;
|
|
610
|
+
const policy = (self) => Effect.gen(function* () {
|
|
611
|
+
const result = yield* self;
|
|
612
|
+
yield* Ref.set(state, plan.nextState);
|
|
613
|
+
return result;
|
|
614
|
+
});
|
|
615
|
+
return yield* policy(inner.mutantRun({
|
|
616
|
+
...options,
|
|
617
|
+
reloadEnvironment: plan.reloadEnvironment
|
|
618
|
+
}));
|
|
619
|
+
})
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
/** "command" — the name this runner answers to in the options. */
|
|
623
|
+
const commandRunnerName = "command";
|
|
624
|
+
/** Whether a configured runner name selects this runner. */
|
|
625
|
+
const isCommandRunner = (name) => name.toLowerCase() === commandRunnerName;
|
|
626
|
+
/** Each call is a fresh process, so the environment is always reloadable. */
|
|
627
|
+
const commandRunnerCapabilities = { reloadEnvironment: true };
|
|
628
|
+
/** One test result standing for the whole command, decided by its exit code. */
|
|
629
|
+
const resultFromExit = (exitCode, output, timeSpentMs) => {
|
|
630
|
+
if (exitCode === 0) return {
|
|
631
|
+
status: "complete",
|
|
632
|
+
tests: [{
|
|
633
|
+
id: "all",
|
|
634
|
+
name: ALL_TESTS_NAME,
|
|
635
|
+
status: "success",
|
|
636
|
+
timeSpentMs
|
|
637
|
+
}]
|
|
638
|
+
};
|
|
639
|
+
return {
|
|
640
|
+
status: "complete",
|
|
641
|
+
tests: [{
|
|
642
|
+
id: "all",
|
|
643
|
+
name: ALL_TESTS_NAME,
|
|
644
|
+
status: "failed",
|
|
645
|
+
failureMessage: output,
|
|
646
|
+
timeSpentMs
|
|
647
|
+
}]
|
|
648
|
+
};
|
|
649
|
+
};
|
|
650
|
+
const mutantActivation = (activeMutantId) => Match.value(activeMutantId).pipe(Match.when(Predicate.isString, (id) => ({
|
|
651
|
+
env: { [INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]: id },
|
|
652
|
+
extendEnv: true
|
|
653
|
+
})), Match.orElse(() => void 0));
|
|
654
|
+
const spawnResult = (outcome, elapsed) => Match.value(outcome).pipe(Match.tag("Failure", (failed) => ({
|
|
655
|
+
status: "error",
|
|
656
|
+
errorMessage: String(failed.cause)
|
|
657
|
+
})), Match.tag("Success", (exited) => resultFromExit(exited.value.exitCode, exited.value.output, elapsed)), Match.exhaustive);
|
|
658
|
+
/**
|
|
659
|
+
* Run the configured command once.
|
|
660
|
+
*
|
|
661
|
+
* `activeMutantId` is threaded through the environment rather than the command
|
|
662
|
+
* line, which is how the instrumented code finds the mutant to activate.
|
|
663
|
+
*/
|
|
664
|
+
const runCommand = (config, activeMutantId) => Effect.gen(function* () {
|
|
665
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
666
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
667
|
+
const command = ChildProcess.make(config.options.commandRunner.command, {
|
|
668
|
+
shell: true,
|
|
669
|
+
cwd: config.workingDir,
|
|
670
|
+
...mutantActivation(activeMutantId)
|
|
671
|
+
});
|
|
672
|
+
const outcome = yield* Effect.scoped(Effect.gen(function* () {
|
|
673
|
+
const handle = yield* spawner.spawn(command);
|
|
674
|
+
return {
|
|
675
|
+
output: yield* Stream.mkString(Stream.decodeText(handle.all)),
|
|
676
|
+
exitCode: yield* handle.exitCode
|
|
677
|
+
};
|
|
678
|
+
})).pipe(Effect.exit);
|
|
679
|
+
const elapsed = (yield* Clock.currentTimeMillis) - startedAt;
|
|
680
|
+
return spawnResult(outcome, elapsed);
|
|
681
|
+
});
|
|
682
|
+
/** Run the tests with no mutant active, to establish the baseline. */
|
|
683
|
+
const commandRunnerDryRun = (config) => runCommand(config, void 0);
|
|
684
|
+
/** Run the tests with one mutant active. */
|
|
685
|
+
const commandRunnerMutantRun = (config, { activeMutant }) => runCommand(config, activeMutant.id).pipe(Effect.map((result) => toMutantRunResult(result, true)));
|
|
686
|
+
/**
|
|
687
|
+
* The command runner, wrapped in the two adjustments that apply to it.
|
|
688
|
+
*
|
|
689
|
+
* No reuse limit and no environment reload, because every call is a new
|
|
690
|
+
* process: there is nothing to reuse and nothing loaded to reload. The
|
|
691
|
+
* combinators applied are the list that remains.
|
|
692
|
+
*
|
|
693
|
+
* The spawner is provided into each operation here rather than appearing in the
|
|
694
|
+
* port's `R`. A port that named its own dependencies would make every consumer
|
|
695
|
+
* discover and supply them (`REPO-A3`), and the service is built exactly where
|
|
696
|
+
* the spawner is already in scope.
|
|
697
|
+
*/
|
|
698
|
+
const commandRunner = (context, spawner) => {
|
|
699
|
+
const config = {
|
|
700
|
+
workingDir: context.sandboxWorkingDirectory,
|
|
701
|
+
options: context.options
|
|
702
|
+
};
|
|
703
|
+
const provided = Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner);
|
|
704
|
+
const base = {
|
|
705
|
+
capabilities: Effect.succeed(commandRunnerCapabilities),
|
|
706
|
+
init: Effect.void,
|
|
707
|
+
dryRun: () => commandRunnerDryRun(config).pipe(provided),
|
|
708
|
+
mutantRun: (options) => commandRunnerMutantRun(config, options).pipe(provided)
|
|
709
|
+
};
|
|
710
|
+
return withRetry(withTimeout(base));
|
|
711
|
+
};
|
|
712
|
+
const inProcessRunner = (context) => Match.value(context.options.testRunner).pipe(Match.when(isCommandRunner, () => Option.some(ChildProcessSpawner.ChildProcessSpawner.pipe(Effect.map((spawner) => commandRunner(context, spawner))))), Match.when(isVmRunner, () => Option.some(vmTestRunner({ testFiles: context.testFiles }).pipe(Effect.provide(nodeVmPlatformLayer)))), Match.orElse(() => Option.none()));
|
|
713
|
+
const buildTestRunner = (context, childProcessRunner) => Effect.gen(function* () {
|
|
714
|
+
const inProcess = inProcessRunner(context);
|
|
715
|
+
if (Option.isSome(inProcess)) return yield* inProcess.value;
|
|
716
|
+
const base = yield* childProcessRunner;
|
|
717
|
+
const timed = withTimeout(base);
|
|
718
|
+
const limited = yield* withMaxReuse(context.options, context.retire)(timed);
|
|
719
|
+
const reloading = yield* withEnvironmentReload(context.retire)(limited);
|
|
720
|
+
return withRetry(reloading);
|
|
721
|
+
});
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region src/run/load-config.cell.ts
|
|
724
|
+
const isNonNullObject = (value) => typeof value === "object" && value !== null;
|
|
725
|
+
const optionsPath = (...path) => path.join(".");
|
|
726
|
+
const combine = (prefixes, suffixes, extensions) => prefixes.flatMap((prefix) => suffixes.flatMap((suffix) => extensions.map((extension) => `${prefix}stryker${suffix}.${extension}`)));
|
|
727
|
+
const CONFIG_FILE_NAME_PREFIXES = ["", "."];
|
|
728
|
+
const CONFIG_FILE_NAME_SUFFIXES = [".conf", ".config"];
|
|
729
|
+
const SUPPORTED_CONFIG_FILE_EXTENSIONS = [
|
|
730
|
+
"ts",
|
|
731
|
+
"mts",
|
|
732
|
+
"js",
|
|
733
|
+
"mjs"
|
|
734
|
+
];
|
|
735
|
+
const LEGACY_CONFIG_FILE_EXTENSIONS = ["json", "cjs"];
|
|
736
|
+
const configFileNames = (extensions) => combine([...CONFIG_FILE_NAME_PREFIXES], [...CONFIG_FILE_NAME_SUFFIXES], [...extensions]);
|
|
737
|
+
const SUPPORTED_CONFIG_FILE_NAMES = Object.freeze(configFileNames(SUPPORTED_CONFIG_FILE_EXTENSIONS));
|
|
738
|
+
const LEGACY_CONFIG_FILE_NAMES = Object.freeze(configFileNames(LEGACY_CONFIG_FILE_EXTENSIONS));
|
|
739
|
+
const SUPPORTED_CONFIG_FILE_EXTENSION_GUIDE = `one of these extensions: ${SUPPORTED_CONFIG_FILE_EXTENSIONS.map((extension) => `.${extension}`).join(", ")}`;
|
|
740
|
+
const legacyConfigHint = (file) => `Stryker no longer reads JSON or CommonJS config files. Convert "${file}" to a config module with an "export default { ... }" object — Stryker reads config modules with ${SUPPORTED_CONFIG_FILE_EXTENSION_GUIDE} — and delete the legacy file.`;
|
|
741
|
+
const unsupportedConfigHint = (file) => `"${file}" is not a supported config file. Stryker reads config modules with ${SUPPORTED_CONFIG_FILE_EXTENSION_GUIDE}, for example "stryker.config.ts" with an "export default { ... }" object.`;
|
|
742
|
+
const extendsChildHint = (file) => `The extended config "${file}" is a JSON or CommonJS config file, which Stryker no longer reads. Convert it to a config module — Stryker reads config modules with ${SUPPORTED_CONFIG_FILE_EXTENSION_GUIDE} — and point "extends" at the converted file.`;
|
|
743
|
+
const shadowedLegacyWarning = (legacyFile, supportedFile) => `Ignoring the legacy config file "${legacyFile}": "${supportedFile}" is the config Stryker reads. Stryker no longer reads JSON or CommonJS config files; delete the legacy file.`;
|
|
744
|
+
const isArrayValue = (value) => Array.isArray(value);
|
|
745
|
+
const freezeArrayValue = (value) => Object.freeze(value.map(deepFreeze));
|
|
746
|
+
const freezeMapEntry = ([entryKey, entryValue]) => [deepFreeze(entryKey), deepFreeze(entryValue)];
|
|
747
|
+
const freezeMapValue = (value) => Object.freeze(new Map([...value.entries()].map(freezeMapEntry)));
|
|
748
|
+
const freezeSetValue = (value) => Object.freeze(new Set([...value.values()].map(deepFreeze)));
|
|
749
|
+
const freezeRegExpValue = (value) => Object.freeze(value);
|
|
750
|
+
const freezeRecordValue = (value) => Object.freeze(Object.entries(value).reduce((frozen, [property, propertyValue]) => {
|
|
751
|
+
frozen[property] = deepFreeze(propertyValue);
|
|
752
|
+
return frozen;
|
|
753
|
+
}, {}));
|
|
754
|
+
function deepFreeze(target) {
|
|
755
|
+
return Match.value(target).pipe(Match.when(isArrayValue, freezeArrayValue), Match.when((value) => value instanceof Map, freezeMapValue), Match.when((value) => value instanceof RegExp, freezeRegExpValue), Match.when((value) => value instanceof Set, freezeSetValue), Match.when(isNonNullObject, freezeRecordValue), Match.orElse(() => target));
|
|
756
|
+
}
|
|
757
|
+
const scopedUnserializable = (scope) => (description) => ({
|
|
758
|
+
...description,
|
|
759
|
+
path: [scope, ...description.path]
|
|
760
|
+
});
|
|
761
|
+
const describeUnserializableChild = (child, scope) => Match.value(findUnserializables(child)).pipe(Match.when(void 0, () => []), Match.orElse((descriptions) => descriptions.map(scopedUnserializable(scope))));
|
|
762
|
+
const collectUnserializables = (groups) => {
|
|
763
|
+
const found = groups.flat();
|
|
764
|
+
if (found.length > 0) return found;
|
|
765
|
+
};
|
|
766
|
+
const describeUnserializableArray = (value) => collectUnserializables(value.map((child, index) => describeUnserializableChild(child, index.toString())));
|
|
767
|
+
const describeUnserializableRecord = (value) => collectUnserializables(Object.entries(value).map(([key, child]) => describeUnserializableChild(child, key)));
|
|
768
|
+
const isPlainObjectValue = (value) => !Array.isArray(value) && value.constructor === Object;
|
|
769
|
+
const classNameOf = (value) => Match.value(value.constructor).pipe(Match.when(Match.defined, (ctor) => ctor.name), Match.orElse(() => "Object"));
|
|
770
|
+
const describeUnserializableInstance = (value) => [{
|
|
771
|
+
path: [],
|
|
772
|
+
reason: `Value is an instance of "${classNameOf(value)}", this detail will get lost in translation during serialization`
|
|
773
|
+
}];
|
|
774
|
+
const describeUnserializableObject = (value) => Match.value(value).pipe(Match.when(isArrayValue, describeUnserializableArray), Match.when(isPlainObjectValue, describeUnserializableRecord), Match.orElse(describeUnserializableInstance));
|
|
775
|
+
const NON_JSON_PRIMITIVE_TYPES = [
|
|
776
|
+
"bigint",
|
|
777
|
+
"function",
|
|
778
|
+
"symbol"
|
|
779
|
+
];
|
|
780
|
+
const isNonJsonPrimitive = (value) => NON_JSON_PRIMITIVE_TYPES.includes(typeof value);
|
|
781
|
+
const describeNonJsonPrimitive = (value) => [{
|
|
782
|
+
path: [],
|
|
783
|
+
reason: `Primitive type "${typeof value}" has no JSON representation`
|
|
784
|
+
}];
|
|
785
|
+
const describeNumber = (value) => {
|
|
786
|
+
if (isFinite(value)) return void 0;
|
|
787
|
+
return [{
|
|
788
|
+
reason: `Number value \`${value}\` has no JSON representation`,
|
|
789
|
+
path: []
|
|
790
|
+
}];
|
|
791
|
+
};
|
|
792
|
+
function findUnserializables(thing) {
|
|
793
|
+
return Match.value(thing).pipe(Match.when((value) => typeof value === "number", describeNumber), Match.when(isNonJsonPrimitive, describeNonJsonPrimitive), Match.when(isNonNullObject, describeUnserializableObject), Match.orElse(() => void 0));
|
|
794
|
+
}
|
|
795
|
+
function isWarningEnabled(warningType, warningOptions) {
|
|
796
|
+
if (typeof warningOptions === "boolean") return warningOptions;
|
|
797
|
+
else return warningOptions[warningType] === true;
|
|
798
|
+
}
|
|
799
|
+
const PATH_LINE = /^at\s+(\[.*\])$/;
|
|
800
|
+
const PATH_SEGMENT = /\["([^"]*)"\]|\[(\d+)\]/g;
|
|
801
|
+
const EXPECTED_PATTERN = /^Expected a string matching the RegExp (.+)$/;
|
|
802
|
+
const EXPECTED_TYPE = /^Expected (.+)$/;
|
|
803
|
+
const appendPathSegment = (path, segment) => Match.value(segment[2]).pipe(Match.when(void 0, () => appendKeySegment(path, segment[1] ?? "")), Match.orElse((index) => `${path}[${index}]`));
|
|
804
|
+
const appendKeySegment = (path, key) => Match.value(path.length > 0).pipe(Match.when(true, () => `${path}.${key}`), Match.orElse(() => `${path}${key}`));
|
|
805
|
+
const dottedPath = (raw) => Array.from(raw.matchAll(PATH_SEGMENT)).reduce(appendPathSegment, "");
|
|
806
|
+
const phraseExpectedType = (expectation) => Match.value(EXPECTED_TYPE.exec(expectation)).pipe(Match.when((match) => match !== null, (match) => `should be ${match[1] ?? ""}`), Match.orElse(() => expectation));
|
|
807
|
+
const phrase = (expectation) => Match.value(EXPECTED_PATTERN.exec(expectation)).pipe(Match.when((match) => match !== null, (match) => `must match pattern "${match[1] ?? ""}"`), Match.orElse(() => phraseExpectedType(expectation)));
|
|
808
|
+
const EMPTY_ERROR_DESCRIPTION_STATE = {
|
|
809
|
+
messages: [],
|
|
810
|
+
expectation: void 0
|
|
811
|
+
};
|
|
812
|
+
const isBlankLine = (line) => line.length === 0;
|
|
813
|
+
const startExpectation = (state, line) => Match.value(state.expectation).pipe(Match.when(void 0, () => ({
|
|
814
|
+
messages: state.messages,
|
|
815
|
+
expectation: line
|
|
816
|
+
})), Match.orElse((pending) => ({
|
|
817
|
+
messages: [...state.messages, pending],
|
|
818
|
+
expectation: line
|
|
819
|
+
})));
|
|
820
|
+
const completeExpectation = (state, pathText, line) => Match.value(state.expectation).pipe(Match.when(void 0, () => startExpectation(state, line)), Match.orElse((pending) => ({
|
|
821
|
+
messages: [...state.messages, `Config option "${dottedPath(pathText)}" ${phrase(pending)}.`],
|
|
822
|
+
expectation: void 0
|
|
823
|
+
})));
|
|
824
|
+
const applyErrorLine = (state, line) => Match.value(PATH_LINE.exec(line)).pipe(Match.when((match) => match !== null, (match) => completeExpectation(state, match[1] ?? "", line)), Match.orElse(() => startExpectation(state, line)));
|
|
825
|
+
const advanceErrorDescription = (state, rawLine) => Match.value(rawLine.trim()).pipe(Match.when(isBlankLine, () => state), Match.orElse((line) => applyErrorLine(state, line)));
|
|
826
|
+
const completedErrorMessages = (state) => Match.value(state.expectation).pipe(Match.when(void 0, () => state.messages), Match.orElse((pending) => [...state.messages, pending]));
|
|
827
|
+
const errorMessageOrFallback = (messages, fallback) => Match.value(messages.length > 0).pipe(Match.when(true, () => [...messages]), Match.orElse(() => [fallback]));
|
|
828
|
+
function describeErrors(error) {
|
|
829
|
+
const state = error.message.split("\n").reduce(advanceErrorDescription, EMPTY_ERROR_DESCRIPTION_STATE);
|
|
830
|
+
return errorMessageOrFallback(completedErrorMessages(state), error.message);
|
|
831
|
+
}
|
|
832
|
+
function importModule(moduleName) {
|
|
833
|
+
return Effect.tryPromise({
|
|
834
|
+
try: () => import(moduleName),
|
|
835
|
+
catch: (cause) => StrykerError.make({
|
|
836
|
+
message: `Failed to import module "${moduleName}"`,
|
|
837
|
+
cause
|
|
838
|
+
})
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const initialExtendsStepState = {
|
|
842
|
+
visited: [],
|
|
843
|
+
documents: []
|
|
844
|
+
};
|
|
845
|
+
const DoneTag = { _tag: "done" };
|
|
846
|
+
const ReadTag = { _tag: "read" };
|
|
847
|
+
const ResolveTag = { _tag: "resolve" };
|
|
848
|
+
const RefusedTag = { _tag: "refused" };
|
|
849
|
+
const asUnknownArray = (value) => {
|
|
850
|
+
if (Array.isArray(value)) return value;
|
|
851
|
+
return [];
|
|
852
|
+
};
|
|
853
|
+
const isFirstDescriptorOccurrence = (descriptors) => (descriptor, index) => typeof descriptor !== "string" || descriptors.slice(0, index).includes(descriptor) === false;
|
|
854
|
+
const mergePluginDescriptors = (parentPlugins, childPlugins) => {
|
|
855
|
+
const merged = [...parentPlugins, ...childPlugins];
|
|
856
|
+
return merged.filter(isFirstDescriptorOccurrence(merged));
|
|
857
|
+
};
|
|
858
|
+
const inheritNested = (parentValue, childValue) => Match.value(parentValue).pipe(Match.when((value) => isNonNullObject(value) && Array.isArray(value) === false, (parentNested) => Match.value(childValue).pipe(Match.when((value) => isNonNullObject(value) && Array.isArray(value) === false, (childNested) => ({
|
|
859
|
+
...parentNested,
|
|
860
|
+
...childNested
|
|
861
|
+
})), Match.orElse(() => childValue))), Match.orElse(() => childValue));
|
|
862
|
+
const inheritEntry = (out, key, parentValue, childValue) => Match.value(childValue).pipe(Match.when(null, () => {
|
|
863
|
+
const next = { ...out };
|
|
864
|
+
delete next[key];
|
|
865
|
+
return next;
|
|
866
|
+
}), Match.orElse(() => Match.value(key).pipe(Match.when("plugins", () => ({
|
|
867
|
+
...out,
|
|
868
|
+
[key]: mergePluginDescriptors(asUnknownArray(parentValue), asUnknownArray(childValue))
|
|
869
|
+
})), Match.orElse(() => ({
|
|
870
|
+
...out,
|
|
871
|
+
[key]: inheritNested(parentValue, childValue)
|
|
872
|
+
})))));
|
|
873
|
+
function mergeConfigs(parent, child) {
|
|
874
|
+
return Object.entries(child).reduce((out, entry) => inheritEntry(out, entry[0], parent[entry[0]], entry[1]), { ...parent });
|
|
875
|
+
}
|
|
876
|
+
const RELATIVE_SPECIFIER_PREFIXES = [
|
|
877
|
+
"./",
|
|
878
|
+
"../",
|
|
879
|
+
"/",
|
|
880
|
+
"\\"
|
|
881
|
+
];
|
|
882
|
+
function isModuleSpecifier(value) {
|
|
883
|
+
return RELATIVE_SPECIFIER_PREFIXES.every((prefix) => value.startsWith(prefix) === false);
|
|
884
|
+
}
|
|
885
|
+
const stripExtends = (document) => {
|
|
886
|
+
const { extends: _ignored, ...rest } = document;
|
|
887
|
+
return rest;
|
|
888
|
+
};
|
|
889
|
+
const mergeChainDocuments = (documents) => documents.reduceRight((merged, entry) => mergeConfigs(merged, stripExtends(entry.options)), {});
|
|
890
|
+
const decideExtendsStep = (state, document, file, pathService) => {
|
|
891
|
+
if (state.visited.includes(file)) return {
|
|
892
|
+
...RefusedTag,
|
|
893
|
+
reason: "cycle",
|
|
894
|
+
file
|
|
895
|
+
};
|
|
896
|
+
const nextState = {
|
|
897
|
+
visited: [...state.visited, file],
|
|
898
|
+
documents: [...state.documents, {
|
|
899
|
+
path: file,
|
|
900
|
+
options: document
|
|
901
|
+
}]
|
|
902
|
+
};
|
|
903
|
+
return Match.value(document["extends"]).pipe(Match.when(void 0, () => ({
|
|
904
|
+
...DoneTag,
|
|
905
|
+
options: mergeChainDocuments(nextState.documents)
|
|
906
|
+
})), Match.when(null, () => ({
|
|
907
|
+
...DoneTag,
|
|
908
|
+
options: mergeChainDocuments(nextState.documents)
|
|
909
|
+
})), Match.when(Match.string, (extendValue) => Match.value(isModuleSpecifier(extendValue)).pipe(Match.when(true, () => ({
|
|
910
|
+
...ResolveTag,
|
|
911
|
+
specifier: extendValue,
|
|
912
|
+
state: nextState
|
|
913
|
+
})), Match.when(false, () => ({
|
|
914
|
+
...ReadTag,
|
|
915
|
+
path: pathService.resolve(pathService.dirname(file), extendValue),
|
|
916
|
+
state: nextState
|
|
917
|
+
})), Match.exhaustive)), Match.orElse(() => ({
|
|
918
|
+
...RefusedTag,
|
|
919
|
+
reason: "non-string-extends",
|
|
920
|
+
file
|
|
921
|
+
})));
|
|
922
|
+
};
|
|
923
|
+
const decodeConfigDocument = (configFile, document) => S.decodeUnknownEffect(ConfigDocumentSchema)(document).pipe(Effect.mapError((cause) => ConfigFileInvalidError.make({
|
|
924
|
+
file: configFile,
|
|
925
|
+
cause
|
|
926
|
+
})));
|
|
927
|
+
const requireDefaultExport = (configFile, defaultExport) => Match.value(defaultExport).pipe(Match.when(void 0, () => Effect.fail(ConfigFileInvalidError.make({
|
|
928
|
+
file: configFile,
|
|
929
|
+
cause: "Config file must have a default export!"
|
|
930
|
+
}))), Match.when(isNonNullObject, (value) => Effect.succeed(value)), Match.orElse(() => Effect.fail(ConfigFileInvalidError.make({
|
|
931
|
+
file: configFile,
|
|
932
|
+
cause: "Default export of config file must be an object!"
|
|
933
|
+
}))));
|
|
934
|
+
const isConfigFactory = (value) => typeof value === "function";
|
|
935
|
+
const factoryFailureCause = (cause) => Match.value(cause).pipe(Match.when(Match.instanceOf(Error), (error) => new Error(`Evaluating the config module's exported factory failed: ${error.message}`, { cause: error })), Match.orElse(() => cause));
|
|
936
|
+
/**
|
|
937
|
+
* Settle a config module's default export into the object the document schema decodes.
|
|
938
|
+
*
|
|
939
|
+
* `./config` types `defineConfig` as accepting the config object, a promise of it,
|
|
940
|
+
* or a factory receiving `ConfigEnv`, so the loader accepts all three. The factory
|
|
941
|
+
* is invoked here because this is the only place that knows the invocation the
|
|
942
|
+
* config is being read for; the promise is awaited here because a promise is a
|
|
943
|
+
* non-null object and would otherwise reach the document decoder as one.
|
|
944
|
+
*/
|
|
945
|
+
const settleConfigDefault = (configFile, defaultExport, configEnv) => Effect.tryPromise({
|
|
946
|
+
try: () => Promise.resolve(defaultExport).then((exported) => Match.value(exported).pipe(Match.when(isConfigFactory, (factory) => factory(configEnv)), Match.orElse((value) => value))),
|
|
947
|
+
catch: (cause) => ConfigFileInvalidError.make({
|
|
948
|
+
file: configFile,
|
|
949
|
+
cause: factoryFailureCause(cause)
|
|
950
|
+
})
|
|
951
|
+
}).pipe(Effect.flatMap((settled) => requireDefaultExport(configFile, settled)));
|
|
952
|
+
const ERASABLE_SYNTAX_HELP = "Config modules may use only erasable TypeScript syntax: no enums, no namespaces with runtime code, no parameter properties, and no decorators.";
|
|
953
|
+
const carriesCode = (cause) => cause instanceof Error && "code" in cause;
|
|
954
|
+
const errorCodeOf = (cause) => {
|
|
955
|
+
const code = Match.value(cause).pipe(Match.when(carriesCode, (error) => error.code), Match.orElse(() => void 0));
|
|
956
|
+
if (typeof code === "string") return code;
|
|
957
|
+
};
|
|
958
|
+
const errorMessageOf = (cause) => {
|
|
959
|
+
const message = Match.value(cause).pipe(Match.when(Match.instanceOf(Error), (error) => error.message), Match.orElse(() => void 0));
|
|
960
|
+
if (typeof message === "string") return message;
|
|
961
|
+
};
|
|
962
|
+
const importFailureDetail = (failure) => errorMessageOf(failure.cause) ?? failure.message;
|
|
963
|
+
const configImportCause = (configFile, failure) => Match.value(errorCodeOf(failure.cause)).pipe(Match.when("ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX", () => new Error(`The config file "${configFile}" uses TypeScript syntax Node cannot execute. ${ERASABLE_SYNTAX_HELP}`, { cause: failure.cause })), Match.when("ERR_UNKNOWN_FILE_EXTENSION", () => new Error(`The config file "${configFile}" has an extension Node cannot load. Stryker reads config modules with ${SUPPORTED_CONFIG_FILE_EXTENSION_GUIDE}.`, { cause: failure.cause })), Match.when("ERR_MODULE_NOT_FOUND", () => new Error(`The config file "${configFile}" imports a module Node cannot find. The specifier is most likely not installed — install it as a dependency of the project, or remove the import. Node reported: ${importFailureDetail(failure)}`, { cause: failure.cause })), Match.when("ERR_PACKAGE_PATH_NOT_EXPORTED", () => new Error(`The config file "${configFile}" imports a path a package does not export. The package is installed but its "exports" map leaves this specifier out — import a path the package publishes. Node reported: ${importFailureDetail(failure)}`, { cause: failure.cause })), Match.orElse(() => failure));
|
|
964
|
+
const configModuleUrl = (configFile, pathService) => Match.value(configFile.startsWith("file:")).pipe(Match.when(true, () => Effect.try({
|
|
965
|
+
try: () => new URL(configFile),
|
|
966
|
+
catch: (cause) => ConfigFileUnreadableError.make({
|
|
967
|
+
file: configFile,
|
|
968
|
+
cause
|
|
969
|
+
})
|
|
970
|
+
})), Match.orElse(() => pathService.toFileUrl(pathService.resolve(configFile)).pipe(Effect.mapError((cause) => ConfigFileUnreadableError.make({
|
|
971
|
+
file: configFile,
|
|
972
|
+
cause
|
|
973
|
+
})))));
|
|
974
|
+
const readConfigModule = (configFile, configEnv) => Effect.gen(function* () {
|
|
975
|
+
const pathService = yield* Path.Path;
|
|
976
|
+
const importedModule = yield* importModule((yield* configModuleUrl(configFile, pathService)).href).pipe(Effect.mapError((failure) => ConfigFileUnreadableError.make({
|
|
977
|
+
file: configFile,
|
|
978
|
+
cause: configImportCause(configFile, failure)
|
|
979
|
+
})));
|
|
980
|
+
const exported = yield* S.decodeUnknownEffect(ImportedModuleSchema)(importedModule).pipe(Effect.mapError((cause) => ConfigFileInvalidError.make({
|
|
981
|
+
file: configFile,
|
|
982
|
+
cause
|
|
983
|
+
})), Effect.map((decoded) => decoded.default));
|
|
984
|
+
const document = yield* settleConfigDefault(configFile, exported, configEnv);
|
|
985
|
+
return yield* decodeConfigDocument(configFile, document);
|
|
986
|
+
});
|
|
987
|
+
const configFileExtension = (configFile, pathService) => pathService.extname(configFile).toLowerCase().slice(1);
|
|
988
|
+
const isLegacyConfigFile = (configFile, pathService) => LEGACY_CONFIG_FILE_EXTENSIONS.includes(configFileExtension(configFile, pathService));
|
|
989
|
+
const readExtendsChild = (configFile, configEnv) => Effect.gen(function* () {
|
|
990
|
+
const pathService = yield* Path.Path;
|
|
991
|
+
return yield* Match.value(isLegacyConfigFile(configFile, pathService)).pipe(Match.when(true, () => Effect.fail(ConfigFileUnsupportedError.make({
|
|
992
|
+
file: configFile,
|
|
993
|
+
hint: extendsChildHint(configFile)
|
|
994
|
+
}))), Match.orElse(() => readConfigModule(configFile, configEnv)));
|
|
995
|
+
});
|
|
996
|
+
const canonicalConfigFile = (file, pathService) => Match.value(file.startsWith("file:")).pipe(Match.when(true, () => Effect.try({
|
|
997
|
+
try: () => new URL(file),
|
|
998
|
+
catch: (cause) => ConfigFileUnreadableError.make({
|
|
999
|
+
file,
|
|
1000
|
+
cause
|
|
1001
|
+
})
|
|
1002
|
+
}).pipe(Effect.flatMap((url) => pathService.fromFileUrl(url).pipe(Effect.mapError((cause) => ConfigFileUnreadableError.make({
|
|
1003
|
+
file,
|
|
1004
|
+
cause
|
|
1005
|
+
})))))), Match.orElse(() => Effect.succeed(pathService.resolve(file))));
|
|
1006
|
+
function resolveExtendsSpecifier(specifier) {
|
|
1007
|
+
return Effect.try({
|
|
1008
|
+
try: () => import.meta.resolve(specifier),
|
|
1009
|
+
catch: (cause) => ConfigFileUnreadableError.make({
|
|
1010
|
+
file: specifier,
|
|
1011
|
+
cause
|
|
1012
|
+
})
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
function resolveExtends(configFile, document, configEnv) {
|
|
1016
|
+
return Effect.gen(function* () {
|
|
1017
|
+
const pathService = yield* Path.Path;
|
|
1018
|
+
const loop = (state, file, currentDocument) => Effect.gen(function* () {
|
|
1019
|
+
const canonicalFile = yield* canonicalConfigFile(file, pathService);
|
|
1020
|
+
return yield* Match.value(decideExtendsStep(state, currentDocument, canonicalFile, pathService)).pipe(Match.tag("done", (d) => Effect.succeed(d.options)), Match.tag("read", (d) => readExtendsChild(d.path, configEnv).pipe(Effect.flatMap((nextDocument) => loop(d.state, d.path, nextDocument)))), Match.tag("resolve", (d) => resolveExtendsSpecifier(d.specifier).pipe(Effect.flatMap((resolvedUrl) => readExtendsChild(resolvedUrl, configEnv).pipe(Effect.flatMap((nextDocument) => loop(d.state, resolvedUrl, nextDocument)))))), Match.tag("refused", (d) => {
|
|
1021
|
+
let message = `Invalid config file "${d.file}". "extends" must be a string`;
|
|
1022
|
+
if (d.reason === "cycle") message = `Config inheritance cycle detected at "${d.file}"`;
|
|
1023
|
+
return Effect.fail(ConfigFileInvalidError.make({
|
|
1024
|
+
file: d.file,
|
|
1025
|
+
cause: message
|
|
1026
|
+
}));
|
|
1027
|
+
}), Match.exhaustive);
|
|
1028
|
+
});
|
|
1029
|
+
return yield* loop(initialExtendsStepState, configFile, document);
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
const forkCoreSchema = S.toJsonSchemaDocument(forkOptionsSchema).schema;
|
|
1033
|
+
const decodeOptions = S.decodeUnknownResult(StrykerOptionsSchema, { errors: "all" });
|
|
1034
|
+
function recordOf(value) {
|
|
1035
|
+
return { ...value };
|
|
1036
|
+
}
|
|
1037
|
+
const thresholdErrors = (options) => Match.value(options.thresholds.high < options.thresholds.low).pipe(Match.when(true, () => ["Config option \"thresholds.high\" should be higher than \"thresholds.low\"."]), Match.orElse(() => []));
|
|
1038
|
+
const ignoreStaticErrors = (options) => Match.value(options.ignoreStatic && options.coverageAnalysis !== "perTest").pipe(Match.when(true, () => [`Config option "${optionsPath("ignoreStatic")}" is not supported with coverage analysis "${options.coverageAnalysis}". Either turn off "${optionsPath("ignoreStatic")}", or configure "${optionsPath("coverageAnalysis")}" to be "perTest".`]), Match.orElse(() => []));
|
|
1039
|
+
const matchLineNumber = (match, group) => Number.parseInt(match[group] ?? "", 10);
|
|
1040
|
+
const startLineErrors = (index, mutationRange, start) => Match.value(start < 1).pipe(Match.when(true, () => [`Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid, line ${start} does not exist (lines start at 1).`]), Match.orElse(() => []));
|
|
1041
|
+
const lineOrderErrors = (index, mutationRange, start, end) => Match.value(start > end).pipe(Match.when(true, () => [`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}).`]), Match.orElse(() => []));
|
|
1042
|
+
const mutationRangeBoundErrors = (index, match) => {
|
|
1043
|
+
const mutationRange = match[2];
|
|
1044
|
+
const start = matchLineNumber(match, 3);
|
|
1045
|
+
const end = matchLineNumber(match, 5);
|
|
1046
|
+
return [...startLineErrors(index, mutationRange, start), ...lineOrderErrors(index, mutationRange, start, end)];
|
|
1047
|
+
};
|
|
1048
|
+
const requireUnmagicalMutationRange = (mutateString, index, match) => Match.value(new Minimatch(mutateString).hasMagic()).pipe(Match.when(true, () => [`Config option "mutate[${index}]" is invalid. Cannot combine a glob expression with a mutation range in "${mutateString}".`]), Match.orElse(() => mutationRangeBoundErrors(index, match)));
|
|
1049
|
+
const mutationRangeErrors = (mutateString, index) => Match.value(MUTATION_RANGE_REGEX.exec(mutateString)).pipe(Match.when((match) => match !== null, (match) => requireUnmagicalMutationRange(mutateString, index, match)), Match.orElse(() => []));
|
|
1050
|
+
const warnOnIgnoredNodeArgs = (nodeArgs) => Match.value(nodeArgs.length > 0).pipe(Match.when(true, () => Effect.logWarning("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.")), Match.orElse(() => Effect.void));
|
|
1051
|
+
const warnOnCommandRunnerNodeArgs = (options) => Match.value(isCommandRunner(options.testRunner)).pipe(Match.when(true, () => warnOnIgnoredNodeArgs(options.testRunnerNodeArgs)), Match.orElse(() => Effect.void));
|
|
1052
|
+
const customValidationErrors = (options) => [
|
|
1053
|
+
...thresholdErrors(options),
|
|
1054
|
+
...ignoreStaticErrors(options),
|
|
1055
|
+
...options.mutate.flatMap(mutationRangeErrors)
|
|
1056
|
+
];
|
|
1057
|
+
function customValidation(options) {
|
|
1058
|
+
const additionalErrors = customValidationErrors(options);
|
|
1059
|
+
return warnOnCommandRunnerNodeArgs(options).pipe(Effect.flatMap(() => logConfigErrors(additionalErrors)), Effect.flatMap(() => throwErrorIfNeeded(additionalErrors)));
|
|
1060
|
+
}
|
|
1061
|
+
function schemaValidate(options) {
|
|
1062
|
+
const decoded = decodeOptions(options);
|
|
1063
|
+
if (Result.isFailure(decoded)) return failWithConfigErrors(describeErrors(decoded.failure));
|
|
1064
|
+
Object.assign(options, decoded.success);
|
|
1065
|
+
return Effect.succeed(decoded.success);
|
|
1066
|
+
}
|
|
1067
|
+
const configErrorHeadline = (errors) => Match.value(errors.length === 1).pipe(Match.when(true, () => "Please correct this configuration error and try again."), Match.orElse(() => "Please correct these configuration errors and try again."));
|
|
1068
|
+
const configErrorMessage = (errors) => `${configErrorHeadline(errors)} ${errors.join(" ")}`;
|
|
1069
|
+
const logConfigErrors = (errors) => Effect.gen(function* () {
|
|
1070
|
+
for (const error of errors) yield* Effect.logError(error);
|
|
1071
|
+
});
|
|
1072
|
+
const failWithConfigErrors = (errors) => logConfigErrors(errors).pipe(Effect.flatMap(() => Effect.fail(ConfigError.make({ message: configErrorMessage(errors) }))));
|
|
1073
|
+
function throwErrorIfNeeded(errors) {
|
|
1074
|
+
if (errors.length === 0) return Effect.void;
|
|
1075
|
+
return Effect.fail(ConfigError.make({ message: configErrorMessage(errors) }));
|
|
1076
|
+
}
|
|
1077
|
+
const OPTIONS_ADDED_BY_STRYKER = [
|
|
1078
|
+
"set",
|
|
1079
|
+
"configFile",
|
|
1080
|
+
"$schema"
|
|
1081
|
+
];
|
|
1082
|
+
const schemaPropertyNames = (schema) => Match.value(schema["properties"]).pipe(Match.when(isNonNullObject, (properties) => Object.keys(recordOf(properties))), Match.orElse(() => []));
|
|
1083
|
+
const excessOptionNames = (options, schema) => {
|
|
1084
|
+
const schemaKeys = schemaPropertyNames(schema);
|
|
1085
|
+
return Object.keys(options).filter((key) => key.endsWith("_comment") === false).filter((key) => OPTIONS_ADDED_BY_STRYKER.includes(key) === false).filter((key) => schemaKeys.includes(key) === false);
|
|
1086
|
+
};
|
|
1087
|
+
const warnAboutUnknownOptions = (options, excessNames) => Effect.gen(function* () {
|
|
1088
|
+
for (const excessPropertyName of excessNames) yield* Effect.logWarning(`Unknown stryker config option "${excessPropertyName}".`);
|
|
1089
|
+
const pluginsJson = yield* S.encodeEffect(S.fromJsonString(S.Array(S.String)))([...options.plugins]).pipe(Effect.orDie);
|
|
1090
|
+
yield* Effect.logWarning(`Possible causes:
|
|
1091
|
+
* Is it a typo on your end?
|
|
1092
|
+
* Did you only write this property as a comment? If so, please postfix it with "_comment".
|
|
1093
|
+
* You might be missing a plugin that is supposed to use it. Stryker loaded plugins from: ${pluginsJson}
|
|
1094
|
+
* The plugin that is using it did not contribute explicit validation.
|
|
1095
|
+
(disable "${optionsPath("warnings", "unknownOptions")}" to ignore this warning)`);
|
|
1096
|
+
});
|
|
1097
|
+
const unknownOptionWarning = (options, schema) => {
|
|
1098
|
+
const excessNames = excessOptionNames(options, schema);
|
|
1099
|
+
return Match.value(excessNames.length > 0).pipe(Match.when(true, () => warnAboutUnknownOptions(options, excessNames)), Match.orElse(() => Effect.void));
|
|
1100
|
+
};
|
|
1101
|
+
function markExcessOptions(options, schema) {
|
|
1102
|
+
return Effect.gen(function* () {
|
|
1103
|
+
if (isWarningEnabled("unknownOptions", options.warnings)) yield* unknownOptionWarning(options, schema);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const logUnserializableWarnings = (unserializables) => Effect.gen(function* () {
|
|
1107
|
+
for (const unserializable of unserializables) yield* Effect.logWarning(`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.`);
|
|
1108
|
+
yield* Effect.logWarning(`(disable ${optionsPath("warnings", "unserializableOptions")} to ignore this warning)`);
|
|
1109
|
+
});
|
|
1110
|
+
const warnAboutUnserializableOptions = (options) => Match.value(findUnserializables(options)).pipe(Match.when(void 0, () => Effect.void), Match.orElse((unserializables) => logUnserializableWarnings(unserializables)));
|
|
1111
|
+
function markUnserializableOptions(options) {
|
|
1112
|
+
return Effect.gen(function* () {
|
|
1113
|
+
if (isWarningEnabled("unserializableOptions", options.warnings)) yield* warnAboutUnserializableOptions(options);
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
function markOptions(options, schema) {
|
|
1117
|
+
return Effect.gen(function* () {
|
|
1118
|
+
yield* markExcessOptions(options, schema);
|
|
1119
|
+
yield* markUnserializableOptions(options);
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
function validateOptions(options, schema) {
|
|
1123
|
+
return Effect.gen(function* () {
|
|
1124
|
+
const typed = yield* schemaValidate(options);
|
|
1125
|
+
yield* customValidation(typed);
|
|
1126
|
+
yield* markOptions(typed, schema);
|
|
1127
|
+
return typed;
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
const createDefaultOptions = S.decodeEffect(StrykerOptionsSchema)({}).pipe(Effect.orDie);
|
|
1131
|
+
Effect.map(createDefaultOptions, (opts) => deepFreeze(opts));
|
|
1132
|
+
`
|
|
1133
|
+
Example of how a config file should look:
|
|
1134
|
+
/**
|
|
1135
|
+
* @type {import('@systemfsoftware/stryker-js-plugin-interface').StrykerOptions}
|
|
1136
|
+
*/
|
|
1137
|
+
export default {
|
|
1138
|
+
// Your options here!
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
A config file is a TypeScript or ESM JavaScript module: stryker.conf.ts, stryker.config.ts, or the same names with a .mts, .js, or .mjs extension.
|
|
1142
|
+
|
|
1143
|
+
See https://stryker-mutator.io/docs/stryker-js/config-file for more information.`.trim();
|
|
1144
|
+
function exists(fileName) {
|
|
1145
|
+
return Effect.gen(function* () {
|
|
1146
|
+
return yield* (yield* FileSystem.FileSystem).access(fileName).pipe(Effect.as(true), Effect.catchTag("PlatformError", (error) => Match.value(error.reason).pipe(Match.tag("NotFound", () => Effect.succeed(false)), Match.orElse(() => Effect.fail(ConfigFileUnreadableError.make({
|
|
1147
|
+
file: fileName,
|
|
1148
|
+
cause: error
|
|
1149
|
+
}))))), Effect.mapError((error) => ConfigFileUnreadableError.make({
|
|
1150
|
+
file: fileName,
|
|
1151
|
+
cause: error
|
|
1152
|
+
})));
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
const requireExistingConfigFile = (configFileName) => exists(configFileName).pipe(Effect.flatMap((doesExist) => Match.value(doesExist).pipe(Match.when(true, () => Effect.succeed(configFileName)), Match.orElse(() => Effect.fail(ConfigFileNotFoundError.make({ file: configFileName }))))));
|
|
1156
|
+
const firstExistingConfigFile = (fileNames) => Option.match(Option.fromUndefinedOr(fileNames[0]), {
|
|
1157
|
+
onNone: () => Effect.succeedNone,
|
|
1158
|
+
onSome: (head) => exists(head).pipe(Effect.flatMap((doesExist) => {
|
|
1159
|
+
if (doesExist) return Effect.succeedSome(head);
|
|
1160
|
+
return firstExistingConfigFile(fileNames.slice(1));
|
|
1161
|
+
}))
|
|
1162
|
+
});
|
|
1163
|
+
const configFileFor = (configFileName) => Effect.gen(function* () {
|
|
1164
|
+
const pathService = yield* Path.Path;
|
|
1165
|
+
const supported = SUPPORTED_CONFIG_FILE_EXTENSIONS.includes(configFileExtension(configFileName, pathService));
|
|
1166
|
+
const legacy = isLegacyConfigFile(configFileName, pathService);
|
|
1167
|
+
return yield* Match.value(supported).pipe(Match.when(true, () => requireExistingConfigFile(configFileName)), Match.orElse(() => Effect.fail(ConfigFileUnsupportedError.make({
|
|
1168
|
+
file: configFileName,
|
|
1169
|
+
hint: Match.value(legacy).pipe(Match.when(true, () => legacyConfigHint(configFileName)), Match.orElse(() => unsupportedConfigHint(configFileName)))
|
|
1170
|
+
}))));
|
|
1171
|
+
});
|
|
1172
|
+
const firstLegacyConfigFile = () => firstExistingConfigFile([...LEGACY_CONFIG_FILE_NAMES]);
|
|
1173
|
+
const legacyConfigError = (file) => ConfigFileUnsupportedError.make({
|
|
1174
|
+
file,
|
|
1175
|
+
hint: legacyConfigHint(file)
|
|
1176
|
+
});
|
|
1177
|
+
const legacyConfigWarning = (file, supportedFile) => Effect.logWarning(shadowedLegacyWarning(file, supportedFile));
|
|
1178
|
+
const refuseLegacyOnlyProject = () => firstLegacyConfigFile().pipe(Effect.flatMap((legacyFile) => Option.match(legacyFile, {
|
|
1179
|
+
onNone: () => Effect.succeedNone,
|
|
1180
|
+
onSome: (file) => Effect.fail(legacyConfigError(file))
|
|
1181
|
+
})));
|
|
1182
|
+
const warnShadowedLegacyConfig = (supportedFile) => firstLegacyConfigFile().pipe(Effect.flatMap((legacyFile) => Option.match(legacyFile, {
|
|
1183
|
+
onNone: () => Effect.succeed(supportedFile),
|
|
1184
|
+
onSome: (file) => Effect.as(legacyConfigWarning(file, supportedFile), supportedFile)
|
|
1185
|
+
})));
|
|
1186
|
+
const discoverConfigFile = () => firstExistingConfigFile([...SUPPORTED_CONFIG_FILE_NAMES]).pipe(Effect.flatMap((found) => Option.match(found, {
|
|
1187
|
+
onNone: () => refuseLegacyOnlyProject().pipe(Effect.map((legacy) => Option.getOrUndefined(legacy))),
|
|
1188
|
+
onSome: (supportedFile) => warnShadowedLegacyConfig(supportedFile)
|
|
1189
|
+
})));
|
|
1190
|
+
function findConfigFile(configFileName) {
|
|
1191
|
+
return Match.value(configFileName).pipe(Match.when(Match.string, (fileName) => configFileFor(fileName)), Match.orElse(() => discoverConfigFile()));
|
|
1192
|
+
}
|
|
1193
|
+
const resolveChildExtends = (configFile, child, configEnv) => Match.value("extends" in child).pipe(Match.when(true, () => resolveExtends(configFile, child, configEnv)), Match.orElse(() => Effect.succeed(child)));
|
|
1194
|
+
const isCiEnvironment = Config.string("CI").pipe(Effect.map((value) => Match.value(value.trim().toLowerCase()).pipe(Match.when("", () => false), Match.when("0", () => false), Match.when("false", () => false), Match.orElse(() => true))), Effect.orElseSucceed(() => false));
|
|
1195
|
+
function loadOptionsFromConfigFile(cliOptions, configEnv) {
|
|
1196
|
+
return findConfigFile(cliOptions["configFile"]).pipe(Effect.flatMap((configFile) => Match.value(configFile).pipe(Match.when(void 0, () => Effect.succeed({})), Match.orElse((found) => readConfigModule(found, configEnv).pipe(Effect.flatMap((child) => resolveChildExtends(found, child, configEnv)))))));
|
|
1197
|
+
}
|
|
1198
|
+
function readConfig(cliOptions, invocation) {
|
|
1199
|
+
return Effect.gen(function* () {
|
|
1200
|
+
const configEnv = {
|
|
1201
|
+
command: invocation.command,
|
|
1202
|
+
mode: invocation.mode,
|
|
1203
|
+
isDryRun: cliOptions["dryRunOnly"] === true,
|
|
1204
|
+
isCi: yield* isCiEnvironment
|
|
1205
|
+
};
|
|
1206
|
+
const cliRecord = yield* S.decodeUnknownEffect(ConfigDocumentSchema)(cliOptions).pipe(Effect.orDie);
|
|
1207
|
+
const fileRecord = yield* loadOptionsFromConfigFile(cliRecord, configEnv);
|
|
1208
|
+
const fileOptions = yield* Result.match(S.decodeUnknownResult(ConfigDocumentSchema)(fileRecord), {
|
|
1209
|
+
onFailure: (cause) => Effect.fail(ConfigFileInvalidError.make({
|
|
1210
|
+
file: "config",
|
|
1211
|
+
cause
|
|
1212
|
+
})),
|
|
1213
|
+
onSuccess: (options) => Effect.succeed(options)
|
|
1214
|
+
});
|
|
1215
|
+
const merged = mergeConfig(fileOptions, cliRecord);
|
|
1216
|
+
const decoded = S.decodeUnknownResult(StrykerOptionsSchema)(merged);
|
|
1217
|
+
if (Result.isFailure(decoded)) throw ConfigError.make({ message: configErrorMessage(describeErrors(decoded.failure)) });
|
|
1218
|
+
return decoded.success;
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
const loadConfigCell = readConfig;
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/Plugins.schema.ts
|
|
1224
|
+
/**
|
|
1225
|
+
* Plugins capability — declarations for plugin module shapes and load failures.
|
|
1226
|
+
*/
|
|
1227
|
+
const PluginDescriptorSchema = Schema.Union([Schema.Struct({
|
|
1228
|
+
kind: WorkerPluginKind,
|
|
1229
|
+
name: Schema.String,
|
|
1230
|
+
workerEntry: WorkerEntryUrl
|
|
1231
|
+
}), Schema.Struct({
|
|
1232
|
+
kind: Schema.Literals(["Evaluator"]),
|
|
1233
|
+
name: Schema.String
|
|
1234
|
+
})]);
|
|
1235
|
+
const PluginModuleSchema = Schema.Struct({ strykerPlugins: Schema.Array(PluginDescriptorSchema) });
|
|
1236
|
+
const isShouldIgnore = (value) => typeof value === "function";
|
|
1237
|
+
const ignoreNothing = () => void 0;
|
|
1238
|
+
const IgnorerEntrySchema = Schema.Struct({
|
|
1239
|
+
name: Schema.String,
|
|
1240
|
+
shouldIgnore: Schema.declare(isShouldIgnore, { toArbitrary: () => (fc) => fc.constant(ignoreNothing) })
|
|
1241
|
+
});
|
|
1242
|
+
const IgnorerModuleSchema = Schema.Struct({ strykerIgnorers: Schema.Array(IgnorerEntrySchema) });
|
|
1243
|
+
const SchemaValidationContributionSchema = Schema.Struct({ strykerValidationSchema: Schema.Record(Schema.String, Schema.Unknown) });
|
|
1244
|
+
var PluginNotFoundError = class extends Schema.TaggedError()("PluginNotFoundError", { descriptor: Schema.String }) {
|
|
1245
|
+
exitClass = "ConfigError";
|
|
1246
|
+
};
|
|
1247
|
+
var PluginLoadFailedError = class extends Schema.TaggedError()("PluginLoadFailedError", {
|
|
1248
|
+
descriptor: Schema.String,
|
|
1249
|
+
cause: Schema.Unknown
|
|
1250
|
+
}) {
|
|
1251
|
+
exitClass = "InternalError";
|
|
1252
|
+
};
|
|
1253
|
+
//#endregion
|
|
1254
|
+
//#region src/Plugins.ts
|
|
1255
|
+
const NO_IGNORERS = [];
|
|
1256
|
+
const buildPluginLoadPlan = (entries) => {
|
|
1257
|
+
const declarations = entries.flatMap((entry, index) => (entry.plugins ?? []).map((plugin) => ({
|
|
1258
|
+
plugin,
|
|
1259
|
+
moduleName: entry.moduleName,
|
|
1260
|
+
entryIndex: index
|
|
1261
|
+
})));
|
|
1262
|
+
const shadowingState = declarations.reduce((acc, declaration, position) => {
|
|
1263
|
+
const key = `${declaration.plugin.kind}:${declaration.plugin.name}`;
|
|
1264
|
+
return {
|
|
1265
|
+
seen: HashMap.set(acc.seen, key, {
|
|
1266
|
+
position,
|
|
1267
|
+
entryIndex: declaration.entryIndex
|
|
1268
|
+
}),
|
|
1269
|
+
shadowings: Option.match(HashMap.get(acc.seen, key), {
|
|
1270
|
+
onNone: () => acc.shadowings,
|
|
1271
|
+
onSome: (previous) => [...acc.shadowings, {
|
|
1272
|
+
kind: declaration.plugin.kind,
|
|
1273
|
+
name: declaration.plugin.name,
|
|
1274
|
+
shadowedIndex: previous.entryIndex,
|
|
1275
|
+
winnerIndex: declaration.entryIndex
|
|
1276
|
+
}]
|
|
1277
|
+
})
|
|
1278
|
+
};
|
|
1279
|
+
}, {
|
|
1280
|
+
seen: HashMap.empty(),
|
|
1281
|
+
shadowings: []
|
|
1282
|
+
});
|
|
1283
|
+
const winningDeclarations = declarations.filter((declaration, position) => Option.match(HashMap.get(shadowingState.seen, `${declaration.plugin.kind}:${declaration.plugin.name}`), {
|
|
1284
|
+
onNone: () => false,
|
|
1285
|
+
onSome: (winner) => winner.position === position
|
|
1286
|
+
}));
|
|
1287
|
+
const pluginsByKind = winningDeclarations.reduce((map, declaration) => Option.match(HashMap.get(map, declaration.plugin.kind), {
|
|
1288
|
+
onNone: () => HashMap.set(map, declaration.plugin.kind, [declaration.plugin]),
|
|
1289
|
+
onSome: (existing) => HashMap.set(map, declaration.plugin.kind, [...existing, declaration.plugin])
|
|
1290
|
+
}), HashMap.empty());
|
|
1291
|
+
const pluginSources = winningDeclarations.map((declaration) => Match.value(declaration.plugin).pipe(Match.when((plugin) => plugin.kind === "Evaluator", (evaluator) => ({
|
|
1292
|
+
kind: "Evaluator",
|
|
1293
|
+
name: evaluator.name,
|
|
1294
|
+
modulePath: declaration.moduleName
|
|
1295
|
+
})), Match.orElse((worker) => ({
|
|
1296
|
+
kind: worker.kind,
|
|
1297
|
+
name: worker.name,
|
|
1298
|
+
modulePath: declaration.moduleName,
|
|
1299
|
+
workerEntry: worker.workerEntry
|
|
1300
|
+
}))));
|
|
1301
|
+
const pluginModulePaths = entries.flatMap((entry) => Option.match(Option.fromUndefinedOr(entry.plugins), {
|
|
1302
|
+
onNone: () => [],
|
|
1303
|
+
onSome: () => [entry.moduleName]
|
|
1304
|
+
}));
|
|
1305
|
+
return {
|
|
1306
|
+
schemaContributions: entries.flatMap((entry) => Option.match(Option.fromUndefinedOr(entry.schemaContribution), {
|
|
1307
|
+
onNone: () => [],
|
|
1308
|
+
onSome: (value) => [value]
|
|
1309
|
+
})),
|
|
1310
|
+
pluginsByKind,
|
|
1311
|
+
pluginModulePaths,
|
|
1312
|
+
pluginSources,
|
|
1313
|
+
shadowings: shadowingState.shadowings
|
|
1314
|
+
};
|
|
1315
|
+
};
|
|
1316
|
+
const failPluginLoad = (descriptor, error) => Effect.logWarning(`Error during loading "${descriptor}" plugin`).pipe(Effect.andThen(() => Effect.fail(PluginLoadFailedError.make({
|
|
1317
|
+
descriptor,
|
|
1318
|
+
cause: error
|
|
1319
|
+
}))));
|
|
1320
|
+
const modulePluginContributions = (module) => Match.value(Predicate.hasProperty(module, "strykerPlugins")).pipe(Match.when(true, () => Schema.decodeUnknownResult(PluginModuleSchema)(module).pipe(Result.map((pluginModule) => pluginModule.strykerPlugins))), Match.orElse(() => Result.succeed(void 0)));
|
|
1321
|
+
const moduleIgnorers = (module) => Match.value(Predicate.hasProperty(module, "strykerIgnorers")).pipe(Match.when(true, () => Schema.decodeUnknownResult(IgnorerModuleSchema)(module).pipe(Result.map((ignorerModule) => ignorerModule.strykerIgnorers))), Match.orElse(() => Result.succeed(void 0)));
|
|
1322
|
+
const moduleSchemaContribution = (module) => Match.value(module).pipe(Match.when(hasValidationSchemaContribution, (contribution) => contribution.strykerValidationSchema), Match.orElse(() => void 0));
|
|
1323
|
+
const pluginContributionsOf = (module) => Result.flatMap(moduleIgnorers(module), (ignorers) => Result.map(modulePluginContributions(module), (plugins) => ({
|
|
1324
|
+
plugins,
|
|
1325
|
+
ignorers,
|
|
1326
|
+
schemaContribution: moduleSchemaContribution(module)
|
|
1327
|
+
})));
|
|
1328
|
+
const hasContribution = (contributions) => [
|
|
1329
|
+
contributions.plugins,
|
|
1330
|
+
contributions.ignorers,
|
|
1331
|
+
contributions.schemaContribution
|
|
1332
|
+
].some((contribution) => contribution !== void 0);
|
|
1333
|
+
const warnUndescribedPluginModule = (descriptor) => Effect.logWarning(`Module "${descriptor}" did not contribute a StrykerJS plugin. It didn't export a "strykerPlugins", "strykerIgnorers", or "strykerValidationSchema".`).pipe(Effect.as(void 0));
|
|
1334
|
+
const describeLoadedPlugin = (descriptor, module) => Result.match(pluginContributionsOf(module), {
|
|
1335
|
+
onFailure: (cause) => failPluginLoad(descriptor, cause),
|
|
1336
|
+
onSuccess: (contributions) => Match.value(hasContribution(contributions)).pipe(Match.when(true, () => Effect.succeed(contributions)), Match.orElse(() => warnUndescribedPluginModule(descriptor)))
|
|
1337
|
+
});
|
|
1338
|
+
function loadPlugin(descriptor, entrypoint) {
|
|
1339
|
+
return Effect.gen(function* () {
|
|
1340
|
+
yield* Effect.logDebug(`Loading plugin ${descriptor}`);
|
|
1341
|
+
const maybeModule = yield* importModule(entrypoint).pipe(Effect.catch((error) => failPluginLoad(descriptor, error)));
|
|
1342
|
+
const module = Option.getOrUndefined(Option.fromUndefinedOr(maybeModule));
|
|
1343
|
+
if (module === void 0) return;
|
|
1344
|
+
return yield* describeLoadedPlugin(descriptor, module);
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
function loadPlugins(pluginDescriptors) {
|
|
1348
|
+
return Effect.gen(function* () {
|
|
1349
|
+
const path = yield* Path.Path;
|
|
1350
|
+
const entrypoints = yield* Effect.forEach(Array$1.fromIterable(HashSet.fromIterable(pluginDescriptors)), (specifier) => path.fromFileUrl(new URL(specifier)).pipe(Effect.map((entrypoint) => ({
|
|
1351
|
+
specifier,
|
|
1352
|
+
entrypoint
|
|
1353
|
+
})), Effect.mapError((cause) => PluginLoadFailedError.make({
|
|
1354
|
+
descriptor: specifier,
|
|
1355
|
+
cause
|
|
1356
|
+
}))), { concurrency: "unbounded" });
|
|
1357
|
+
const loaded = yield* Effect.forEach(entrypoints, (resolved) => loadPlugin(resolved.specifier, resolved.entrypoint).pipe(Effect.map((plugin) => {
|
|
1358
|
+
if (plugin === void 0) return;
|
|
1359
|
+
return {
|
|
1360
|
+
...plugin,
|
|
1361
|
+
moduleName: resolved.entrypoint
|
|
1362
|
+
};
|
|
1363
|
+
})), { concurrency: "unbounded" }).pipe(Effect.map((arr) => arr.filter(Predicate.isNotNullish)));
|
|
1364
|
+
const ignorers = loaded.flatMap((entry) => entry.ignorers ?? NO_IGNORERS);
|
|
1365
|
+
const entries = loaded.map((entry) => ({
|
|
1366
|
+
moduleName: entry.moduleName,
|
|
1367
|
+
plugins: entry.plugins,
|
|
1368
|
+
schemaContribution: entry.schemaContribution
|
|
1369
|
+
}));
|
|
1370
|
+
const plan_ = buildPluginLoadPlan(entries);
|
|
1371
|
+
for (const shadowing of plan_.shadowings) yield* Effect.logWarning(`Plugin "${shadowing.name}" of kind "${shadowing.kind}" at index ${shadowing.winnerIndex} shadows plugin at index ${shadowing.shadowedIndex}.`);
|
|
1372
|
+
return {
|
|
1373
|
+
schemaContributions: plan_.schemaContributions,
|
|
1374
|
+
pluginsByKind: plan_.pluginsByKind,
|
|
1375
|
+
pluginModulePaths: plan_.pluginModulePaths,
|
|
1376
|
+
pluginSources: plan_.pluginSources,
|
|
1377
|
+
ignorers
|
|
1378
|
+
};
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
function hasValidationSchemaContribution(module) {
|
|
1382
|
+
return Schema.is(SchemaValidationContributionSchema)(module);
|
|
1383
|
+
}
|
|
1384
|
+
const findByKindAndName = (items, kind, name) => Option.fromUndefinedOr(items.find((item) => item.kind === kind && item.name.toLowerCase() === name.toLowerCase()));
|
|
1385
|
+
const findContribution = (descriptors, kind, name) => Effect.fromOption(Option.filter(findByKindAndName(descriptors, kind, name), (descriptor) => descriptor.kind === kind), () => PluginNotFoundError.make({ descriptor: `${kind}:${name} (available: ${descriptors.map((d) => d.name).join(", ")})` }));
|
|
1386
|
+
function findPlugin(pluginsByKind, kind, name) {
|
|
1387
|
+
return Option.match(HashMap.get(pluginsByKind, kind), {
|
|
1388
|
+
onNone: () => Effect.fail(PluginNotFoundError.make({ descriptor: `${kind}:${name} (no ${kind} plugins were loaded)` })),
|
|
1389
|
+
onSome: (descriptors) => findContribution(descriptors, kind, name)
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
function create(pluginsByKind, kind, name) {
|
|
1393
|
+
return findPlugin(pluginsByKind, kind, name);
|
|
1394
|
+
}
|
|
1395
|
+
function createAll(pluginsByKind, kind) {
|
|
1396
|
+
const descriptors = HashMap.get(pluginsByKind, kind);
|
|
1397
|
+
if (Option.isNone(descriptors)) return Effect.succeed([]);
|
|
1398
|
+
return Effect.succeed(descriptors.value.filter((d) => d.kind === kind));
|
|
1399
|
+
}
|
|
1400
|
+
//#endregion
|
|
1401
|
+
//#region src/plugin-worker-entry.ts
|
|
1402
|
+
const missingWorkerEntry = (stage, kind, name) => (failure) => StageError.make({
|
|
1403
|
+
stage,
|
|
1404
|
+
reason: `the ${kind} plugin "${name}" is not among the loaded plugins`,
|
|
1405
|
+
cause: failure
|
|
1406
|
+
});
|
|
1407
|
+
const requiredSource = (params) => Effect.fromOption(findByKindAndName(params.loaded.pluginSources, params.kind, params.name), () => PluginNotFoundError.make({ descriptor: `${params.kind}:${params.name}` }));
|
|
1408
|
+
const resolvePluginWorkerEntry = (params) => requiredSource(params).pipe(Effect.map((source) => ({
|
|
1409
|
+
kind: params.kind,
|
|
1410
|
+
entrypoint: source.workerEntry
|
|
1411
|
+
})));
|
|
1412
|
+
//#endregion
|
|
1413
|
+
export { PrepareError as $, OutOfMemoryError as A, ConfigDocumentSchema as B, connectRetry as C, layer as D, classifyWorkerExit as E, vmRunnerName as F, ConfigFileUnsupportedError as G, ConfigFileInvalidError as H, vmTestRunner as I, MergeResult as J, ImportedModuleSchema as K, StrykerError as L, VmRunner as M, isVmRunner as N, makeConcurrency as O, vmRunnerCapabilities as P, survivorsPriorReport as Q, ALWAYS_IGNORE as R, WorkerLauncher as S, IdGenerator as T, ConfigFileNotFoundError as U, ConfigError as V, ConfigFileUnreadableError as W, extendsPropertySchema as X, ReadConfigCommand as Y, forkOptionsSchema as Z, validateOptions as _, loadPlugins as a, makeChildProcessTestRunner as b, decideExtendsStep as c, importModule as d, StageError as et, initialExtendsStepState as f, resolveExtends as g, readConfig as h, createAll as i, WorkerBootTimeoutError as j, ChildProcessCrashedError as k, describeErrors as l, mergeConfigs as m, resolvePluginWorkerEntry as n, PluginLoadFailedError as o, loadConfigCell as p, MergeCommand as q, create as r, PluginNotFoundError as s, missingWorkerEntry as t, forkCoreSchema as u, buildTestRunner as v, makeWorkerClient as w, nodePlatformLayer as x, isCommandRunner as y, MUTATION_RANGE_REGEX as z };
|